diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
new file mode 100644
index 0000000..1748b38
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -0,0 +1,79 @@
+name: π Bug
+description: Report a bug or an issue you've found
+title: "[Bug]
"
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+ - type: checkboxes
+ attributes:
+ label: Is this a new bug?
+ description: >
+ In other words: Is this an error, flaw, failure or fault? Please search issues to see if someone has already reported the bug you encountered.
+ options:
+ - label: I believe this is a new bug
+ required: true
+ - label: I have searched the existing issues, and I could not find an existing issue for this bug
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Current Behavior
+ description: A concise description of what you're experiencing.
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Expected Behavior
+ description: A concise description of what you expected to happen.
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Steps To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. In this environment...
+ 2. With this config...
+ 3. Run '...'
+ 4. See error...
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Environment
+ description: |
+ examples:
+ - **OS**: Ubuntu 20.04
+ - **Language version**: Python 3.10.11 (`python --version`)
+ - **SDK Name**: PayPal (If you are using this library as a dependency, please name the parent SDK)
+ value: |
+ - **OS**:
+ - **Language version**:
+ - **SDK Name**:
+ render: markdown
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: |
+ If applicable, log output to help explain your problem.
+ render: shell
+ validations:
+ required: false
+
+ - type: textarea
+ attributes:
+ label: Additional Context
+ description: |
+ Links? References? Anything that will give us more context about the issue you are encountering!
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 22a70ea..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: bug, reported-by-consumer
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. (step 1)
-2. (step 2)
-3. (step 3)
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Environment**
-For example: Windows/Linux
-
-**Library version**
-For example: 0.1.0
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 0000000..c5dd8ce
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,54 @@
+name: β¨ Feature
+description: Propose an extension
+title: "[Feature] "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this feature request!
+ - type: checkboxes
+ attributes:
+ label: Is this your first time submitting a feature request?
+ description: >
+ We want to make sure that features are distinct and discoverable,
+ so that other members of the community can find them and offer their thoughts.
+
+ Issues are the right place to request extensions of existing functionality.
+ options:
+ - label: I have searched the existing issues, and I could not find an existing issue for this feature
+ required: true
+ - label: I am requesting an extension of the existing functionality
+ - type: textarea
+ attributes:
+ label: Describe the feature
+ description: A clear and concise description of what you want to happen.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Describe alternatives you've considered
+ description: |
+ A clear and concise description of any alternative solutions or features you've considered.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Who will this benefit?
+ description: |
+ What kind of use case will this feature be useful for? Please be specific and provide examples, this will help us prioritize properly.
+ validations:
+ required: false
+ - type: input
+ attributes:
+ label: Are you interested in contributing this feature?
+ description: Let us know if you want to write some code, and how we can help.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Anything else?
+ description: |
+ Links? References? Anything that will give us more context about the feature you are suggesting!
+ validations:
+ required: false
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..9278187
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,20 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ labels:
+ - "dependency update"
+ commit-message:
+ prefix: "build"
+ include: "scope"
+ open-pull-requests-limit: 10
+ rebase-strategy: "auto"
+ reviewers:
+ - "sufyankhanrao"
diff --git a/.github/workflows/dependabot-notifications.yml b/.github/workflows/dependabot-notifications.yml
new file mode 100644
index 0000000..e16a921
--- /dev/null
+++ b/.github/workflows/dependabot-notifications.yml
@@ -0,0 +1,126 @@
+name: Dependabot Notifications
+
+on:
+ workflow_run:
+ workflows: ["*"]
+ types:
+ - completed
+
+jobs:
+ notify-checks:
+ runs-on: ubuntu-latest
+ if: github.actor == 'dependabot[bot]'
+ steps:
+ - name: Get PR Information
+ if: github.actor == 'dependabot[bot]'
+ id: get-pr-info
+ uses: actions/github-script@v6
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const run = context.payload.workflow_run;
+
+ // Get PR directly from the workflow run's head SHA
+ const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner,
+ repo,
+ commit_sha: run.head_sha
+ });
+
+ const pr = response.data[0]; // Get the first associated PR
+
+ if (pr) {
+ core.exportVariable('PR_TITLE', pr.title);
+ core.exportVariable('PR_AUTHOR', pr.user.login);
+ core.exportVariable('PR_LINK', pr.html_url);
+ core.exportVariable('PR_NUMBER', pr.number.toString());
+ } else {
+ core.exportVariable('PR_TITLE', 'Unknown');
+ core.exportVariable('PR_AUTHOR', context.actor);
+ core.exportVariable('PR_LINK', `https://github.com/${owner}/${repo}/pulls`);
+ core.exportVariable('PR_NUMBER', '');
+ }
+
+ // Get check runs for this commit
+ const checkRuns = await github.rest.checks.listForRef({
+ owner,
+ repo,
+ ref: run.head_sha
+ });
+
+ // Count different check conclusions
+ const stats = checkRuns.data.check_runs.reduce((acc, check) => {
+ acc[check.conclusion] = (acc[check.conclusion] || 0) + 1;
+ return acc;
+ }, {});
+
+ // Create status summary
+ const summary = Object.entries(stats)
+ .map(([status, count]) => `${count} ${status}`)
+ .join(', ');
+
+ core.exportVariable('CHECKS_SUMMARY', summary);
+
+ // Determine overall status
+ const hasFailures = stats.failure > 0;
+ const hasSuccess = stats.success > 0;
+ const hasCancelled = stats.cancelled > 0;
+
+ let overallStatus;
+ if (hasFailures) {
+ overallStatus = 'failure';
+ } else if (hasCancelled && !hasSuccess) {
+ overallStatus = 'cancelled';
+ } else if (hasSuccess) {
+ overallStatus = 'success';
+ } else {
+ overallStatus = 'unknown';
+ }
+
+ // Only set status if this is the last workflow to complete
+ const incompleteRuns = await github.rest.actions.listWorkflowRunsForRepo({
+ owner,
+ repo,
+ head_sha: run.head_sha,
+ status: 'in_progress'
+ });
+
+ if (incompleteRuns.data.total_count === 0) {
+ core.exportVariable('ALL_CHECKS_STATUS', overallStatus);
+ core.exportVariable('SHOULD_NOTIFY', 'true');
+
+ // If checks failed and PR exists, close it
+ if ((overallStatus === 'failure' || overallStatus === 'cancelled') && pr) {
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: pr.number,
+ state: 'closed'
+ });
+
+ // Add comment explaining why PR was closed
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: pr.number,
+ body: `This PR was automatically closed because some checks failed.\nStatus Summary: ${summary}`
+ });
+ }
+ } else {
+ core.exportVariable('SHOULD_NOTIFY', 'false');
+ }
+
+ - name: Send Slack Notification for Success
+ if: env.SHOULD_NOTIFY == 'true' && env.ALL_CHECKS_STATUS == 'success' && github.actor == 'dependabot[bot]'
+ id: slack
+ uses: slackapi/slack-github-action@v1.25.0
+ with:
+ channel-id: 'C08TLGVQ6V8'
+ slack-message: |
+ Repository: ${{ github.repository }}
+ Title: ${{ env.PR_TITLE }}
+ Author: ${{ env.PR_AUTHOR }}
+ Link: ${{ env.PR_LINK }}
+ Status Summary: ${{ env.CHECKS_SUMMARY }}
+ env:
+ SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 6e1bfaf..2c27b44 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -30,7 +30,7 @@ jobs:
- name: Publish distribution to PyPI
id: release
- uses: pypa/gh-action-pypi-publish@v1.5.1
+ uses: pypa/gh-action-pypi-publish@v1.13.0
with:
password: ${{ secrets.PYPI_TOKEN }}
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index 2d67c32..06313d4 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -1,6 +1,6 @@
name: Pylint Runner
-on:
+on:
workflow_dispatch
jobs:
@@ -9,8 +9,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest]
- python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+ os: [ubuntu-22.04]
+ python: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v3
- name: Setup Python
@@ -33,5 +33,5 @@ jobs:
run: |
for file in $(find -name '*.py')
do
- pylint --disable=R,C,W "$file" --fail-under=10;
+ pylint --disable=R,C,W "$file" --fail-under=10;
done
diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml
index 229212e..33121ab 100644
--- a/.github/workflows/test-runner.yml
+++ b/.github/workflows/test-runner.yml
@@ -15,8 +15,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
- os: [ubuntu-latest]
- python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+ os: [ubuntu-22.04]
+ python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v3
- name: Setup Python
@@ -32,11 +32,10 @@ jobs:
run: coverage run -m pytest
- name: Generate coverage report
run: coverage xml
- - name: Upload coverage report
- if: ${{ matrix.os == 'ubuntu-latest' && matrix.python == '3.11' }}
- uses: paambaati/codeclimate-action@v3.0.0
+
+ - name: SonarQube Scan
+ if: ${{ matrix.python == '3.13' && github.actor != 'dependabot[bot]' }}
+ uses: SonarSource/sonarqube-scan-action@v6.0.0
env:
- CC_TEST_REPORTER_ID: ${{ secrets.CODE_CLIMATE_TEST_REPORTER_ID }}
- with:
- coverageLocations: |
- ${{github.workspace}}/coverage.xml:coverage.py
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index b0b6f3a..de820c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -157,4 +157,9 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-.idea/
\ No newline at end of file
+.idea/
+
+# Visual Studio Code
+.vscode/
+.qodo
+*~
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..756f41d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,60 @@
+# Contributing to apimatic/core-lib-python
+
+Thank you for your interest in contributing! π Your contributions help make this project better. We value open-source contributions to this library, please take a few minutes to review this guide before you start.
+
+---
+
+## π‘ How to Contribute
+
+### π 1. Reporting Issues
+
+- Search existing issues before opening a new one.
+- Use a **descriptive title** and provide clear steps to reproduce.
+- Include relevant logs, screenshots, or error messages.
+
+### π§ 2. Making Changes
+
+#### Step 1: Create a Branch
+
+- Create a new branch from `main`:
+ ```sh
+ git checkout -b your-feature-name
+ ```
+
+#### Step 2: Make Your Changes
+
+- Follow the project's **coding standards**.
+- Add **unit tests** if applicable.
+- Ensure your changes **do not break existing functionality**.
+
+#### Step 3: Commit Changes
+
+- Use clear and descriptive commit messages:
+ ```sh
+ git commit -m "feat: Add feature description"
+ ```
+
+#### Step 4: Push & Open a PR
+
+- Push your branch:
+ ```sh
+ git push origin your-feature-name
+ ```
+- Open a **Pull Request (PR)** on GitHub:
+ - Provide a clear **description** of the changes.
+ - Mention related **issue numbers**.
+ - Request a **review** from maintainers.
+ - Make sure your changes clear all the **PR Checks**.
+
+---
+
+## π License
+
+By contributing, you agree that your contributions will be licensed under the project's [LICENSE](LICENSE).
+
+---
+
+π¬ **Questions?**
+If you need help, feel free to open issues, we will be happy to help.
+
+Happy Coding! π
diff --git a/README.md b/README.md
index cd78c1d..c02c453 100644
--- a/README.md
+++ b/README.md
@@ -1,122 +1,202 @@
# apimatic-core
+
[![PyPI][pypi-version]][apimatic-core-pypi-url]
[![Tests][test-badge]][test-url]
-[![Test Coverage][test-coverage-url]][code-climate-url]
+[![Test Coverage][coverage-badge]][coverage-url]
+[![Maintainability Rating][maintainability-badge]][maintainability-url]
+[![Vulnerabilities][vulnerabilities-badge]][vulnerabilities-url]
[![Licence][license-badge]][license-url]
## Introduction
-The APIMatic Core libraries provide a stable runtime that powers all the functionality of SDKs. This includes functionality like the ability to create HTTP requests, handle responses, apply authentication schemes, convert API responses back to object instances, and validate user and server data.
+The APIMatic Core libraries provide a stable runtime that powers all the functionality of SDKs.
+This includes functionality like the ability to create HTTP requests, handle responses, apply authentication schemes, convert API responses back to object instances, validate user and server data, and more advanced features like templating and secure signature verification.
+
+---
## Installation
-You will need Python 3.7-3.11 to support this package.
-Simply run the command below to install the core library in your SDK. The core library will be added as a dependency your SDK.
+You will need Python 3.7+ to support this package.
-```php
+```bash
pip install apimatic-core
```
+
+---
+
## API Call Classes
-| Name | Description |
-|-------------------------------------------------------------|-----------------------------------------------------------------------|
-| [`RequestBuilder`](apimatic_core/request_builder.py) | A builder class used to build an API Request |
-| [`APICall`](apimatic_core/api_call.py) | A class used to create an API Call object |
-| [`ResponseHandler`](apimatic_core/response_handler.py ) | Used to handle the response returned by the server |
+| Name | Description |
+| ------------------------------------------------------ | -------------------------------------------------- |
+| [`RequestBuilder`](apimatic_core/request_builder.py) | A builder class used to build an API Request |
+| [`APICall`](apimatic_core/api_call.py) | A class used to create an API Call object |
+| [`ResponseHandler`](apimatic_core/response_handler.py) | Used to handle the response returned by the server |
+
+---
## Authentication
-| Name | Description |
-|--------------------------------------------------------------------|--------------------------------------------------------------------------------------|
-| [`HeaderAuth`](apimatic_core/authentication/header_auth.py) | A class supports HTTP authentication through HTTP Headers |
-| [`QueryAuth`](apimatic_core/authentication/query_auth.py) | A class supports HTTP authentication through query parameters |
-| [`AuthGroup`](apimatic_core/authentication/multiple/auth_group.py) | A helper class to support multiple authentication operation |
-| [`And`](apimatic_core/authentication/multiple/and_auth_group.py) | A helper class to support AND operation between multiple authentication types |
-| [`Or`](apimatic_core/authentication/multiple/or_auth_group.py) | A helper class to support OR operation between multiple authentication types |
-| [`Single`](apimatic_core/authentication/multiple/single_auth.py) | A helper class to support single authentication |
+| Name | Description |
+| ------------------------------------------------------------------ | ------------------------------------------------------- |
+| [`HeaderAuth`](apimatic_core/authentication/header_auth.py) | HTTP authentication via headers |
+| [`QueryAuth`](apimatic_core/authentication/query_auth.py) | HTTP authentication via query parameters |
+| [`AuthGroup`](apimatic_core/authentication/multiple/auth_group.py) | Supports grouping of multiple authentication operations |
+| [`And`](apimatic_core/authentication/multiple/and_auth_group.py) | Logical AND grouping for multiple authentication types |
+| [`Or`](apimatic_core/authentication/multiple/or_auth_group.py) | Logical OR grouping for multiple authentication types |
+| [`Single`](apimatic_core/authentication/multiple/single_auth.py) | Represents a single authentication type |
+
+---
## Configurations
-| Name | Description |
-|----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
-| [`EndpointConfiguration`](apimatic_core/configurations/endpoint_configuration.py)| A class which hold the possible configurations for an endpoint |
-| [`GlobalConfiguration`](apimatic_core/configurations/global_configuration.py ) | A class which hold the global configuration properties to make a successful Api Call |
+
+| Name | Description |
+| --------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
+| [`EndpointConfiguration`](apimatic_core/configurations/endpoint_configuration.py) | Holds configurations specific to an endpoint |
+| [`GlobalConfiguration`](apimatic_core/configurations/global_configuration.py) | Holds global configuration properties to make a successful API call |
+
+---
## Decorators
-| Name | Description |
-|--------------------------------------------------------------|--------------------------------------------------------------------------------------|
-| [`LazyProperty`](apimatic_core/decorators/lazy_property.py) | A decorator class for lazy instantiation |
+
+| Name | Description |
+| ----------------------------------------------------------- | -------------------------------- |
+| [`LazyProperty`](apimatic_core/decorators/lazy_property.py) | Decorator for lazy instantiation |
+
+---
## Exceptions
-| Name | Description |
-|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
-| [`OneOfValidationException`](apimatic_core/exceptions/oneof_validation_exception.py) | An exception class for the failed validation of oneOf (union-type) cases |
-| [`AnyOfValidationException`](apimatic_core/exceptions/anyof_validation_exception.py) | An exception class for the failed validation of anyOf (union-type) cases |
-| [`AuthValidationException`](apimatic_core/exceptions/auth_validation_exception.py) | An exception class for the failed validation of authentication schemes |
+
+| Name | Description |
+| ---------------------------------------------------------------------------------------- | ------------------------------------------------------- |
+| [`OneOfValidationException`](apimatic_core/exceptions/oneof_validation_exception.py) | Thrown on failed validation of oneOf union-type cases |
+| [`AnyOfValidationException`](apimatic_core/exceptions/anyof_validation_exception.py) | Thrown on failed validation of anyOf union-type cases |
+| [`AuthValidationException`](apimatic_core/exceptions/auth_validation_exception.py) | Thrown when authentication scheme validation fails |
+
+---
## Factories
-| Name | Description |
-|---------------------------------------------------------------------------|-----------------------------------------------------------------------------|
-| [`HttpResponseFactory`](apimatic_core/factories/http_response_factory.py) | A factory class to create an HTTP Response |
+
+| Name | Description |
+| ------------------------------------------------------------------------- | -------------------------------- |
+| [`HttpResponseFactory`](apimatic_core/factories/http_response_factory.py) | Factory to create HTTP responses |
+
+---
+
+## HTTP Configurations
+| Name | Description |
+|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
+| [`HttpClientConfiguration`](apimatic_core/http/configurations/http_client_configuration.py) | A class used for configuring the SDK by a user |
+| [`ProxySettings`](apimatic_core/http/configurations/proxy_settings.py) | ProxySettings encapsulates HTTP proxy configuration for Requests, e.g. address, port and optional basic authentication for HTTP and HTTPS |
+
+---
## HTTP
-| Name | Description |
-|---------------------------------------------------------------------------------------------|-------------------------------------------------------------|
-| [`HttpCallBack`](apimatic_core/factories/http_response_factory.py) | A factory class to create an HTTP Response |
-| [`HttpClientConfiguration`](apimatic_core/http/configurations/http_client_configuration.py) | A class used for configuring the SDK by a user |
-| [`HttpRequest`](apimatic_core/http/request/http_request.py) | A class which contains information about the HTTP Response |
-| [`ApiResponse`](apimatic_core/http/response/api_response.py) | A wrapper class for Api Response |
-| [`HttpResponse`](apimatic_core/http/response/http_response.py) | A class which contains information about the HTTP Response |
+
+| Name | Description |
+| ------------------------------------------------------------------------------------------- | ------------------------------------------ |
+| [`HttpCallBack`](apimatic_core/factories/http_response_factory.py) | Callback handler for HTTP lifecycle events |
+| [`HttpRequest`](apimatic_core/http/request/http_request.py) | Represents an HTTP request |
+| [`ApiResponse`](apimatic_core/http/response/api_response.py) | Wraps an API response |
+| [`HttpResponse`](apimatic_core/http/response/http_response.py) | Represents an HTTP response |
+
+---
## Logging Configuration
-| Name | Description |
-|------------------------------------------------------------------------------------------------------|-------------------------------------------------------------|
-| [`ApiLoggingConfiguration`](apimatic_core/logger/configuration/api_logging_configuration.py) | Holds overall logging configuration for logging an API call |
-| [`ApiRequestLoggingConfiguration`](apimatic_core/logger/configuration/api_logging_configuration.py) | Holds logging configuration for API request |
-| [`ApiResponseLoggingConfiguration`](apimatic_core/logger/configuration/api_logging_configuration.py) | Holds logging configuration for API response |
+| Name | Description |
+| ---------------------------------------------------------------------------------------------------- | ------------------------------------------ |
+| [`ApiLoggingConfiguration`](apimatic_core/logger/configuration/api_logging_configuration.py) | Global logging configuration for API calls |
+| [`ApiRequestLoggingConfiguration`](apimatic_core/logger/configuration/api_logging_configuration.py) | Request logging configuration |
+| [`ApiResponseLoggingConfiguration`](apimatic_core/logger/configuration/api_logging_configuration.py) | Response logging configuration |
+
+---
## Logger
-| Name | Description |
-|-----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| [`SdkLogger`](apimatic_core/logger/sdk_logger.py) | Responsible for logging the request and response of an API call, it represents the default implementation of ApiLogger when there exist any logging configuration |
-| [`NoneSdkLogger`](apimatic_core/logger/sdk_logger.py) | Represents the default implementation for ApiLogger when no logging configuration is provided |
-| [`ConsoleLogger`](apimatic_core/logger/default_logger.py) | Represents the default implementation for Logger when no custom implementation is provided |
-| [`LoggerFactory`](apimatic_core/logger/sdk_logger.py) | Responsible for providing the ApiLogger implementation (`SdkLogger` \| `NoneSdkLogger`) based on the logging configuration |
+
+| Name | Description |
+| --------------------------------------------------------- | ------------------------------------------------------------------ |
+| [`SdkLogger`](apimatic_core/logger/sdk_logger.py) | Logs requests and responses when logging configuration is provided |
+| [`NoneSdkLogger`](apimatic_core/logger/sdk_logger.py) | No-op logger used when logging is disabled |
+| [`ConsoleLogger`](apimatic_core/logger/default_logger.py) | Simple console logger implementation |
+| [`LoggerFactory`](apimatic_core/logger/sdk_logger.py) | Provides appropriate logger instances based on configuration |
+
+---
## Types
-| Name | Description |
-|-------------------------------------------------------------------------------|------------------------------------------------------------------------------|
-| [`SerializationFormats`](apimatic_core/types/array_serialization_format.py) | An Enumeration of Array serialization formats |
-| [`DateTimeFormat`](apimatic_core/types/datetime_format.py ) | An Enumeration of Date Time formats |
-| [`ErrorCase`](apimatic_core/types/error_case.py ) | A class to represent Exception types |
-| [`FileWrapper`](apimatic_core/types/file_wrapper.py) | A wrapper to allow passing in content type for file uploads |
-| [`Parameter`](apimatic_core/types/parameter.py ) | A class to represent information about a Parameter passed in an endpoint |
-| [`XmlAttributes`](apimatic_core/types/xml_attributes.py ) | A class to represent information about a XML Parameter passed in an endpoint |
-| [`OneOf`](apimatic_core/types/union_types/one_of.py ) | A class to represent information about OneOf union types |
-| [`AnyOf`](apimatic_core/types/union_types/any_of.py ) | A class to represent information about AnyOf union types |
-| [`LeafType`](apimatic_core/types/union_types/leaf_type.py ) | A class to represent the case information in an OneOf or AnyOf union type |
+
+| Name | Description |
+| --------------------------------------------------------------------------- | ------------------------------------------------- |
+| [`SerializationFormats`](apimatic_core/types/array_serialization_format.py) | Enumeration of array serialization formats |
+| [`DateTimeFormat`](apimatic_core/types/datetime_format.py) | Enumeration of DateTime formats |
+| [`ErrorCase`](apimatic_core/types/error_case.py) | Represents exception types |
+| [`FileWrapper`](apimatic_core/types/file_wrapper.py) | Wraps files for upload with content-type |
+| [`Parameter`](apimatic_core/types/parameter.py) | Represents an API parameter |
+| [`XmlAttributes`](apimatic_core/types/xml_attributes.py) | Represents XML parameter metadata |
+| [`OneOf`](apimatic_core/types/union_types/one_of.py) | Represents OneOf union types |
+| [`AnyOf`](apimatic_core/types/union_types/any_of.py) | Represents AnyOf union types |
+| [`LeafType`](apimatic_core/types/union_types/leaf_type.py) | Represents a specific case in a OneOf/AnyOf union |
+
+---
+
+## Pagination
+
+| Name | Description |
+| ------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
+| [`CursorPagination`](apimatic_core/pagination/strategies/cursor_pagination.py) | Cursor-based pagination helper |
+| [`LinkPagination`](apimatic_core/pagination/strategies/link_pagination.py) | Link-based pagination helper |
+| [`OffsetPagination`](apimatic_core/pagination/strategies/offset_pagination.py) | Offset-based pagination helper |
+| [`PagePagination`](apimatic_core/pagination/strategies/page_pagination.py) | Page-number-based pagination helper |
+| [`PaginatedData`](apimatic_core/pagination/paginated_data.py) | Iterable interface to traverse items and pages in a paginated API |
+
+---
## Utilities
-| Name | Description |
-|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
-| [`ApiHelper`](apimatic_core/utilities/api_helper.py) | A Helper Class with various functions associated with making an API Call |
-| [`AuthHelper`](apimatic_core/utilities/auth_helper.py) | A Helper Class with various functions associated with authentication in API Calls |
-| [`ComparisonHelper`](apimatic_core/utilities/comparison_helper.py) | A Helper Class used for the comparison of expected and actual API response |
-| [`FileHelper`](apimatic_core/utilities/file_helper.py) | A Helper Class for files |
-| [`XmlHelper`](apimatic_core/utilities/xml_helper.py ) | A Helper class that holds utility methods for xml serialization and deserialization. |
-| [`DateTimeHelper`](apimatic_core/utilities/datetime_helper.py ) | A Helper class that holds utility methods for validation of different datetime formats. |
-| [`UnionTypeHelper`](apimatic_core/utilities/union_type_helper.py ) | A Helper class that holds utility methods for deserialization and validation of OneOf/AnyOf union types. |
+
+| Name | Description |
+| ------------------------------------------------------------------ | ---------------------------------------------------------- |
+| [`ApiHelper`](apimatic_core/utilities/api_helper.py) | Helper functions for API calls |
+| [`AuthHelper`](apimatic_core/utilities/auth_helper.py) | Helper functions for authentication |
+| [`ComparisonHelper`](apimatic_core/utilities/comparison_helper.py) | Utilities for response comparison |
+| [`FileHelper`](apimatic_core/utilities/file_helper.py) | File handling utilities |
+| [`XmlHelper`](apimatic_core/utilities/xml_helper.py) | XML serialization/deserialization helpers |
+| [`DateTimeHelper`](apimatic_core/utilities/datetime_helper.py) | Date/time parsing and validation helpers |
+| [`UnionTypeHelper`](apimatic_core/utilities/union_type_helper.py) | Deserialization and validation for OneOf/AnyOf union types |
+
+---
+
+## **Signature Verification**
+
+| Name | Description |
+|------------------------------------------------------------------------------------------------------| ------------------------------------------------------------------------------------ |
+| [`HmacSignatureVerifier`](apimatic_core/security/signature_verifiers/hmac_signature_verifier.py) | Verifies HMAC signatures using configurable templates, hash algorithms, and encoders |
+| [`HexEncoder`](apimatic_core/security/signature_verifiers/hmac_signature_verifier.py) | Encodes digest as lowercase hex |
+| [`Base64Encoder`](apimatic_core/security/signature_verifiers/hmac_signature_verifier.py) | Encodes digest as Base64 |
+| [`Base64UrlEncoder`](apimatic_core/security/signature_verifiers/hmac_signature_verifier.py) | Encodes digest as URL-safe Base64 without padding |
+
+This layer enables secure handling of webhooks, callbacks, and API integrations that rely on HMAC or other signing strategies.
+
+---
+
+| Name | Description |
+|-------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [`to_unified_request`](apimatic_core/adapters/request_adapter.py) | **Sync** wrapper for Flask/Django (WSGI). Unwraps Flask `LocalProxy` when present and bridges to the async converter using an event loop. |
+| [`to_unified_request_async`](apimatic_core/adapters/request_adapter.py) | **Async** adapter that converts Starlette/FastAPI, Flask/Werkzeug, or Django requests into a framework-agnostic `Request` snapshot (method, path, url, headers, raw body, query, form, cookies). |
+
+---
## Links
-* [apimatic-core-interfaces](https://pypi.org/project/apimatic-core-interfaces/)
+* [apimatic-core-interfaces](https://pypi.org/project/apimatic-core-interfaces/)
[pypi-version]: https://img.shields.io/pypi/v/apimatic-core
[apimatic-core-pypi-url]: https://pypi.org/project/apimatic-core/
[test-badge]: https://github.com/apimatic/core-lib-python/actions/workflows/test-runner.yml/badge.svg
[test-url]: https://github.com/apimatic/core-lib-python/actions/workflows/test-runner.yml
-[code-climate-url]: https://codeclimate.com/github/apimatic/core-lib-python
-[maintainability-url]: https://api.codeclimate.com/v1/badges/32e7abfdd4d27613ae76/maintainability
-[test-coverage-url]: https://api.codeclimate.com/v1/badges/32e7abfdd4d27613ae76/test_coverage
+[coverage-badge]: https://sonarcloud.io/api/project_badges/measure?project=apimatic_core-lib-python&metric=coverage
+[coverage-url]: https://sonarcloud.io/summary/new_code?id=apimatic_core-lib-python
+[maintainability-badge]: https://sonarcloud.io/api/project_badges/measure?project=apimatic_core-lib-python&metric=sqale_rating
+[maintainability-url]: https://sonarcloud.io/summary/new_code?id=apimatic_core-lib-python
+[vulnerabilities-badge]: https://sonarcloud.io/api/project_badges/measure?project=apimatic_core-lib-python&metric=vulnerabilities
+[vulnerabilities-url]: https://sonarcloud.io/summary/new_code?id=apimatic_core-lib-python
[license-badge]: https://img.shields.io/badge/licence-MIT-blue
[license-url]: LICENSE
diff --git a/apimatic_core/__init__.py b/apimatic_core/__init__.py
index d233d02..a429909 100644
--- a/apimatic_core/__init__.py
+++ b/apimatic_core/__init__.py
@@ -11,5 +11,7 @@
'types',
'logger',
'exceptions',
- 'constants'
+ 'constants',
+ 'pagination',
+ 'security'
]
\ No newline at end of file
diff --git a/apimatic_core/adapters/__init__.py b/apimatic_core/adapters/__init__.py
new file mode 100644
index 0000000..90ada3d
--- /dev/null
+++ b/apimatic_core/adapters/__init__.py
@@ -0,0 +1,4 @@
+__all__ = [
+ 'request_adapter',
+ 'types',
+]
\ No newline at end of file
diff --git a/apimatic_core/adapters/request_adapter.py b/apimatic_core/adapters/request_adapter.py
new file mode 100644
index 0000000..916378c
--- /dev/null
+++ b/apimatic_core/adapters/request_adapter.py
@@ -0,0 +1,188 @@
+# apimatic_core/adapters/request_adapter.py
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any, Dict, List, Mapping, Optional, Union
+
+from http.cookies import SimpleCookie
+
+from apimatic_core_interfaces.http.request import Request
+from apimatic_core.adapters.types.django_request_like import DjangoRequestLike
+from apimatic_core.adapters.types.flask_request_like import FlaskRequestLike
+from apimatic_core.adapters.types.starlette_request_like import StarletteRequestLike
+
+
+# -----------------------
+# Shared utilities
+# -----------------------
+
+def _as_listdict(obj: Any) -> Dict[str, List[str]]:
+ if not obj:
+ return {}
+ getlist = getattr(obj, "getlist", None)
+ if callable(getlist):
+ return {str(k): list(getlist(k)) for k in obj.keys()}
+ return {str(k): [str(v)] for k, v in dict(obj).items()}
+
+
+def _content_type(headers: Mapping[str, str]) -> str:
+ """Return lower-cased Content-Type value or empty string."""
+ return (headers.get("content-type") or headers.get("Content-Type") or "").lower()
+
+
+def _is_urlencoded_or_multipart(headers: Mapping[str, str]) -> bool:
+ """Check if body is form-like (urlencoded/multipart)."""
+ ct = _content_type(headers)
+ return ct.startswith(("multipart/form-data", "application/x-www-form-urlencoded"))
+
+
+def _cookies_from_header(headers: Mapping[str, str]) -> Dict[str, str]:
+ """Parse Cookie header into a dict, returns {} if absent/empty."""
+ cookie_header = headers.get("Cookie") or headers.get("cookie")
+ if not cookie_header:
+ return {}
+ jar = SimpleCookie()
+ jar.load(cookie_header)
+ return {k: morsel.value for k, morsel in jar.items()}
+
+
+def _django_headers_fallback(req: DjangoRequestLike) -> Dict[str, str]:
+ """
+ Fallback for very old Django where `request.headers` is missing/empty.
+ Builds headers from META['HTTP_*'] entries.
+ """
+ meta = getattr(req, "META", {}) or {}
+ return {
+ k[5:].replace("_", "-"): str(v)
+ for k, v in meta.items()
+ if isinstance(k, str) and k.startswith("HTTP_")
+ }
+
+
+def _unwrap_local_proxy(obj: Any) -> Any:
+ """
+ Best-effort unwrapping for LocalProxy-like objects (e.g., Werkzeug/Flask).
+ If `_get_current_object` exists and works, return the underlying object.
+ If calling it raises, swallow and return the original object.
+ If it doesn't exist, return the original object.
+ """
+ get_current = getattr(obj, "_get_current_object", None)
+ if callable(get_current):
+ try:
+ return get_current()
+ except Exception:
+ return obj
+ return obj
+
+
+# -----------------------
+# Per-framework converters
+# -----------------------
+
+async def _from_starlette(req: StarletteRequestLike) -> Request:
+ headers = dict(req.headers)
+ raw = await req.body()
+ query = _as_listdict(req.query_params)
+ cookies = dict(req.cookies)
+ url_str = str(req.url)
+ path = req.url.path
+
+ form: Dict[str, List[str]] = {}
+ if _is_urlencoded_or_multipart(headers):
+ form_data = await req.form()
+ for k in form_data.keys():
+ # Filter out file-like parts (e.g., UploadFile: has filename & read)
+ values = [
+ str(v)
+ for v in form_data.getlist(k)
+ if not (hasattr(v, "filename") and hasattr(v, "read"))
+ ]
+ if values:
+ form[k] = values
+
+ return Request(
+ method=req.method,
+ path=path,
+ url=url_str,
+ headers=headers,
+ raw_body=raw,
+ query=query,
+ cookies=cookies,
+ form=form,
+ )
+
+
+def _from_flask(req: FlaskRequestLike) -> Request:
+ headers = dict(req.headers)
+ url_str: Optional[str] = getattr(req, "url", None)
+ path: str = req.path
+ raw: bytes = req.get_data(cache=True)
+ query = _as_listdict(req.args)
+ cookies = dict(req.cookies) or _cookies_from_header(headers)
+ form = _as_listdict(req.form)
+
+ return Request(
+ method=req.method,
+ path=path,
+ url=url_str,
+ headers=headers,
+ raw_body=raw,
+ query=query,
+ cookies=cookies,
+ form=form,
+ )
+
+
+def _from_django(req: DjangoRequestLike) -> Request:
+ headers = dict(getattr(req, "headers", {}) or {}) or _django_headers_fallback(req)
+ url_str = req.build_absolute_uri()
+ path = req.path
+ raw = bytes(getattr(req, "body", b"") or b"")
+ query = _as_listdict(getattr(req, "GET", {}))
+ cookies = dict(getattr(req, "COOKIES", {}) or {})
+ form = _as_listdict(getattr(req, "POST", {}))
+
+ return Request(
+ method=req.method,
+ path=path,
+ url=url_str,
+ headers=headers,
+ raw_body=raw,
+ query=query,
+ cookies=cookies,
+ form=form,
+ )
+
+
+# -----------------------
+# Public API
+# -----------------------
+
+async def to_unified_request_async(
+ req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike]
+) -> Request:
+ """
+ Convert a framework request (Starlette/FastAPI, Flask/Werkzeug, or Django) to a unified snapshot.
+
+ Uses structural typing to detect the request βshapeβ and extracts an immutable snapshot
+ (no file uploads). See per-framework helpers for exact extraction rules.
+ """
+ if isinstance(req, StarletteRequestLike):
+ return await _from_starlette(req)
+ if isinstance(req, FlaskRequestLike):
+ return _from_flask(req)
+ if isinstance(req, DjangoRequestLike):
+ return _from_django(req)
+ raise TypeError(f"Unsupported request type: {type(req)!r}")
+
+
+def to_unified_request(
+ req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike, Any]
+) -> Request:
+ """
+ Synchronous wrapper around `to_unified_request` with LocalProxy unwrapping.
+ """
+ unwrapped = _unwrap_local_proxy(req)
+ # We expect to be called from sync code; create and run a fresh loop.
+ return asyncio.run(to_unified_request_async(unwrapped))
diff --git a/apimatic_core/adapters/types/__init__.py b/apimatic_core/adapters/types/__init__.py
new file mode 100644
index 0000000..71ffbe1
--- /dev/null
+++ b/apimatic_core/adapters/types/__init__.py
@@ -0,0 +1,5 @@
+__all__ = [
+ 'django_request_like.py',
+ 'flask_request_like.py',
+ 'starlette_request_like.py'
+]
\ No newline at end of file
diff --git a/apimatic_core/adapters/types/django_request_like.py b/apimatic_core/adapters/types/django_request_like.py
new file mode 100644
index 0000000..87de95e
--- /dev/null
+++ b/apimatic_core/adapters/types/django_request_like.py
@@ -0,0 +1,13 @@
+from typing import Any, Mapping
+from typing_extensions import Protocol, runtime_checkable
+
+@runtime_checkable
+class DjangoRequestLike(Protocol):
+ method: str
+ headers: Mapping[str, str]
+ COOKIES: Mapping[str, str]
+ GET: Mapping[str, Any]
+ POST: Mapping[str, Any]
+ path: str
+ body: bytes
+ def build_absolute_uri(self) -> str: ...
diff --git a/apimatic_core/adapters/types/flask_request_like.py b/apimatic_core/adapters/types/flask_request_like.py
new file mode 100644
index 0000000..c990d9e
--- /dev/null
+++ b/apimatic_core/adapters/types/flask_request_like.py
@@ -0,0 +1,13 @@
+from typing import Any, Mapping
+from typing_extensions import Protocol, runtime_checkable
+
+@runtime_checkable
+class FlaskRequestLike(Protocol):
+ method: str
+ headers: Mapping[str, str]
+ cookies: Mapping[str, str]
+ args: Mapping[str, Any]
+ url: str
+ path: str
+ def get_data(self, cache: bool = ...) -> bytes: ...
+ form: Mapping[str, Any]
diff --git a/apimatic_core/adapters/types/starlette_request_like.py b/apimatic_core/adapters/types/starlette_request_like.py
new file mode 100644
index 0000000..7156f37
--- /dev/null
+++ b/apimatic_core/adapters/types/starlette_request_like.py
@@ -0,0 +1,12 @@
+from typing import Any, Mapping, Awaitable
+from typing_extensions import Protocol, runtime_checkable
+
+@runtime_checkable
+class StarletteRequestLike(Protocol):
+ method: str
+ headers: Mapping[str, str]
+ cookies: Mapping[str, str]
+ query_params: Mapping[str, Any]
+ url: Any
+ def body(self) -> Awaitable[bytes]: ...
+ def form(self) -> Awaitable[Any]: ...
diff --git a/apimatic_core/api_call.py b/apimatic_core/api_call.py
index d3b5b6e..4a00b56 100644
--- a/apimatic_core/api_call.py
+++ b/apimatic_core/api_call.py
@@ -1,8 +1,9 @@
from apimatic_core.configurations.endpoint_configuration import EndpointConfiguration
from apimatic_core.configurations.global_configuration import GlobalConfiguration
from apimatic_core.logger.sdk_logger import LoggerFactory
+from apimatic_core.pagination.paginated_data import PaginatedData
from apimatic_core.response_handler import ResponseHandler
-
+import copy
class ApiCall:
@@ -10,6 +11,18 @@ class ApiCall:
def new_builder(self):
return ApiCall(self._global_configuration)
+ @property
+ def request_builder(self):
+ return self._request_builder
+
+ @property
+ def get_pagination_strategies(self):
+ return self._pagination_strategies
+
+ @property
+ def global_configuration(self):
+ return self._global_configuration
+
def __init__(
self,
global_configuration=GlobalConfiguration()
@@ -20,6 +33,7 @@ def __init__(
self._endpoint_configuration = EndpointConfiguration()
self._api_logger = LoggerFactory.get_api_logger(self._global_configuration.get_http_client_configuration()
.logging_configuration)
+ self._pagination_strategies = None
def request(self, request_builder):
self._request_builder = request_builder
@@ -29,6 +43,10 @@ def response(self, response_handler):
self._response_handler = response_handler
return self
+ def pagination_strategies(self, *pagination_strategies):
+ self._pagination_strategies = pagination_strategies
+ return self
+
def endpoint_configuration(self, endpoint_configuration):
self._endpoint_configuration = endpoint_configuration
return self
@@ -62,3 +80,25 @@ def execute(self):
_http_callback.on_after_response(_http_response)
return self._response_handler.handle(_http_response, self._global_configuration.get_global_errors())
+
+
+ def paginate(self, page_iterable_creator, paginated_items_converter):
+ return page_iterable_creator(PaginatedData(self, paginated_items_converter))
+
+ def clone(self, global_configuration=None, request_builder=None, response_handler=None,
+ endpoint_configuration=None, pagination_strategies=None):
+ new_instance = copy.deepcopy(self)
+ new_instance._global_configuration = global_configuration or self._global_configuration
+ new_instance._request_builder = request_builder or self._request_builder
+ new_instance._response_handler = response_handler or self._response_handler
+ new_instance._endpoint_configuration = endpoint_configuration or self._endpoint_configuration
+ new_instance._pagination_strategies = pagination_strategies or self._pagination_strategies
+ return new_instance
+
+ def __deepcopy__(self, memodict={}):
+ copy_instance = ApiCall(self._global_configuration)
+ copy_instance._request_builder = copy.deepcopy(self._request_builder, memo=memodict)
+ copy_instance._response_handler = copy.deepcopy(self._response_handler, memo=memodict)
+ copy_instance._endpoint_configuration = copy.deepcopy(self._endpoint_configuration, memo=memodict)
+ copy_instance._pagination_strategies = copy.deepcopy(self._pagination_strategies, memo=memodict)
+ return copy_instance
diff --git a/apimatic_core/configurations/endpoint_configuration.py b/apimatic_core/configurations/endpoint_configuration.py
index b90fae7..1eb5f04 100644
--- a/apimatic_core/configurations/endpoint_configuration.py
+++ b/apimatic_core/configurations/endpoint_configuration.py
@@ -1,25 +1,60 @@
-
class EndpointConfiguration:
+ """Configuration for an API endpoint, including binary response handling,
+ retry behavior, and request builder management.
+ """
+
+ def __init__(self):
+ self._has_binary_response = None
+ self._to_retry = None
@property
def contains_binary_response(self):
+ """Indicates whether the response is expected to be binary."""
return self._has_binary_response
@property
def should_retry(self):
+ """Indicates whether the request should be retried on failure."""
return self._to_retry
- def __init__(
- self
- ):
- self._has_binary_response = None
- self._to_retry = None
-
def has_binary_response(self, has_binary_response):
+ """Sets whether the response should be treated as binary.
+
+ Args:
+ has_binary_response (bool): True if the response is binary.
+
+ Returns:
+ EndpointConfiguration: The current instance for chaining.
+ """
self._has_binary_response = has_binary_response
return self
def to_retry(self, to_retry):
+ """Sets whether the request should be retried on failure.
+
+ Args:
+ to_retry (bool): True if retries should be attempted.
+
+ Returns:
+ EndpointConfiguration: The current instance for chaining.
+ """
self._to_retry = to_retry
return self
+ def __deepcopy__(self, memo={}):
+ if id(self) in memo:
+ return memo[id(self)]
+ copy_instance = EndpointConfiguration()
+ copy_instance._has_binary_response = self._has_binary_response
+ copy_instance._to_retry = self._to_retry
+ memo[id(self)] = copy_instance
+ return copy_instance
+
+ def clone_with(self, **kwargs):
+ clone_instance = self.__deepcopy__()
+ for key, value in kwargs.items():
+ if hasattr(clone_instance, key):
+ setattr(clone_instance, key, value)
+ else:
+ raise AttributeError(f"'EndpointConfiguration' object has no attribute '{key}'")
+ return clone_instance
\ No newline at end of file
diff --git a/apimatic_core/configurations/global_configuration.py b/apimatic_core/configurations/global_configuration.py
index 2befe69..065dd49 100644
--- a/apimatic_core/configurations/global_configuration.py
+++ b/apimatic_core/configurations/global_configuration.py
@@ -1,6 +1,8 @@
+from requests.structures import CaseInsensitiveDict
+
from apimatic_core.http.configurations.http_client_configuration import HttpClientConfiguration
from apimatic_core.utilities.api_helper import ApiHelper
-
+import copy
class GlobalConfiguration:
@@ -27,8 +29,8 @@ def __init__(
):
self._http_client_configuration = http_client_configuration
self._global_errors = {}
- self._global_headers = {}
- self._additional_headers = {}
+ self._global_headers = CaseInsensitiveDict()
+ self._additional_headers = CaseInsensitiveDict()
self._auth_managers = {}
self._base_uri_executor = None
@@ -70,3 +72,18 @@ def add_useragent_in_global_headers(self, user_agent, user_agent_parameters):
user_agent, user_agent_parameters).replace(' ', ' ')
if user_agent:
self._global_headers['user-agent'] = user_agent
+
+ def clone_with(self, http_client_configuration=None):
+ clone_instance = self.__deepcopy__()
+ clone_instance._http_client_configuration = http_client_configuration or self._http_client_configuration
+ return clone_instance
+
+ def __deepcopy__(self, memo={}):
+ copy_instance = GlobalConfiguration()
+ copy_instance._http_client_configuration = copy.deepcopy(self._http_client_configuration, memo)
+ copy_instance._global_errors = copy.deepcopy(self._global_errors, memo)
+ copy_instance._global_headers = copy.deepcopy(self._global_headers, memo)
+ copy_instance._additional_headers = copy.deepcopy(self._additional_headers, memo)
+ copy_instance._auth_managers = copy.deepcopy(self._auth_managers, memo)
+ copy_instance._base_uri_executor = copy.deepcopy(self._base_uri_executor, memo)
+ return copy_instance
\ No newline at end of file
diff --git a/apimatic_core/http/__init__.py b/apimatic_core/http/__init__.py
index 32788c7..9c23a2b 100644
--- a/apimatic_core/http/__init__.py
+++ b/apimatic_core/http/__init__.py
@@ -2,5 +2,6 @@
'http_callback',
'configurations',
'request',
- 'response'
+ 'response',
+ 'http_call_context'
]
\ No newline at end of file
diff --git a/apimatic_core/http/configurations/__init__.py b/apimatic_core/http/configurations/__init__.py
index 9cc094b..79c209c 100644
--- a/apimatic_core/http/configurations/__init__.py
+++ b/apimatic_core/http/configurations/__init__.py
@@ -1,3 +1,4 @@
__all__ = [
- 'http_client_configuration'
+ 'http_client_configuration',
+ 'proxy_settings'
]
\ No newline at end of file
diff --git a/apimatic_core/http/configurations/http_client_configuration.py b/apimatic_core/http/configurations/http_client_configuration.py
index 41b7f35..80a001f 100644
--- a/apimatic_core/http/configurations/http_client_configuration.py
+++ b/apimatic_core/http/configurations/http_client_configuration.py
@@ -1,6 +1,6 @@
-# -*- coding: utf-8 -*-
-from apimatic_core.factories.http_response_factory import HttpResponseFactory
+from apimatic_core.factories.http_response_factory import HttpResponseFactory
+import copy
class HttpClientConfiguration(object): # pragma: no cover
"""A class used for configuring the SDK by a user.
@@ -50,10 +50,15 @@ def retry_methods(self):
def logging_configuration(self):
return self._logging_configuration
+ @property
+ def proxy_settings(self):
+ return self._proxy_settings
+
def __init__(self, http_client_instance=None,
override_http_client_configuration=False, http_call_back=None,
timeout=60, max_retries=0, backoff_factor=2,
- retry_statuses=None, retry_methods=None, logging_configuration=None):
+ retry_statuses=None, retry_methods=None, logging_configuration=None,
+ proxy_settings=None):
if retry_statuses is None:
retry_statuses = [408, 413, 429, 500, 502, 503, 504, 521, 522, 524]
@@ -93,5 +98,18 @@ def __init__(self, http_client_instance=None,
self._logging_configuration = logging_configuration
+ self._proxy_settings = proxy_settings
+
def set_http_client(self, http_client):
self._http_client = http_client
+
+ def clone(self, http_callback=None):
+ http_client_instance = HttpClientConfiguration(
+ http_client_instance=self.http_client_instance,
+ override_http_client_configuration=self.override_http_client_configuration,
+ http_call_back=http_callback or self.http_callback,
+ timeout=self.timeout, max_retries=self.max_retries, backoff_factor=self.backoff_factor,
+ retry_statuses=self.retry_statuses, retry_methods=self.retry_methods,
+ logging_configuration=self.logging_configuration, proxy_settings=self.proxy_settings)
+ http_client_instance.set_http_client(self.http_client)
+ return http_client_instance
\ No newline at end of file
diff --git a/apimatic_core/http/configurations/proxy_settings.py b/apimatic_core/http/configurations/proxy_settings.py
new file mode 100644
index 0000000..00d2e05
--- /dev/null
+++ b/apimatic_core/http/configurations/proxy_settings.py
@@ -0,0 +1,81 @@
+from typing import Dict, Optional
+from urllib.parse import quote
+
+
+class ProxySettings:
+ """
+ A simple data model for configuring HTTP(S) proxy settings.
+ """
+
+ HTTP_SCHEME: str = "http://"
+ HTTPS_SCHEME: str = "https://"
+
+ address: str
+ port: Optional[int]
+ username: Optional[str]
+ password: Optional[str]
+
+ def __init__(self, address: str, port: Optional[int] = None,
+ username: Optional[str] = None, password: Optional[str] = None) -> None:
+ """
+ Parameters
+ ----------
+ address : str
+ Hostname or IP of the proxy.
+ port : int, optional
+ Port of the proxy server.
+ username : str, optional
+ Username for authentication.
+ password : str, optional
+ Password for authentication.
+ """
+ self.address = address
+ self.port = port
+ self.username = username
+ self.password = password
+
+ def __repr__(self) -> str:
+ """
+ Developer-friendly representation.
+ """
+ return (
+ f"ProxySettings(address={self.address!r}, "
+ f"port={self.port!r}, "
+ f"username={self.username!r}, "
+ f"password={'***' if self.password else None})"
+ )
+
+ def __str__(self) -> str:
+ """
+ Human-friendly string for display/logging.
+ """
+ user_info = f"{self.username}:***@" if self.username else ""
+ port = f":{self.port}" if self.port else ""
+ return f"{user_info}{self.address}{port}"
+
+ def _sanitize_address(self) -> str:
+ addr = (self.address or "").strip()
+ # Trim scheme if present
+ if addr.startswith(self.HTTP_SCHEME):
+ addr = addr[len(self.HTTP_SCHEME):]
+ elif addr.startswith(self.HTTPS_SCHEME):
+ addr = addr[len(self.HTTPS_SCHEME):]
+ # Drop trailing slash if user typed a URL-like form
+ return addr.rstrip("/")
+
+ def to_proxies(self) -> Dict[str, str]:
+ """
+ Build a `requests`-compatible proxies dictionary.
+ """
+ host = self._sanitize_address()
+ auth = ""
+ if self.username is not None:
+ # URL-encode in case of special chars
+ u = quote(self.username, safe="")
+ p = quote(self.password or "", safe="")
+ auth = f"{u}:{p}@"
+ port = f":{self.port}" if self.port is not None else ""
+ return {
+ "http": f"{self.HTTP_SCHEME}{auth}{host}{port}",
+ "https": f"{self.HTTPS_SCHEME}{auth}{host}{port}",
+ }
diff --git a/apimatic_core/http/http_call_context.py b/apimatic_core/http/http_call_context.py
new file mode 100644
index 0000000..15f7fb3
--- /dev/null
+++ b/apimatic_core/http/http_call_context.py
@@ -0,0 +1,41 @@
+from apimatic_core.http.http_callback import HttpCallBack
+
+
+class HttpCallContext(HttpCallBack):
+
+ @property
+ def request(self):
+ return self._request
+
+ @property
+ def response(self):
+ return self._response
+
+ def __init__(self):
+ self._request = None
+ self._response = None
+
+ """An interface for the callback to be called before and after the
+ HTTP call for an endpoint is made.
+
+ This class should not be instantiated but should be used as a base class
+ for HttpCallBack classes.
+
+ """
+
+ def on_before_request(self, request): # pragma: no cover
+ """The controller will call this method before making the HttpRequest.
+
+ Args:
+ request (HttpRequest): The request object which will be sent
+ to the HttpClient to be executed.
+ """
+ self._request = request
+
+ def on_after_response(self, response): # pragma: no cover
+ """The controller will call this method after making the HttpRequest.
+
+ Args:
+ response (HttpResponse): The HttpResponse of the API call.
+ """
+ self._response = response
\ No newline at end of file
diff --git a/apimatic_core/logger/configuration/api_logging_configuration.py b/apimatic_core/logger/configuration/api_logging_configuration.py
index af6d4ae..a8326bb 100644
--- a/apimatic_core/logger/configuration/api_logging_configuration.py
+++ b/apimatic_core/logger/configuration/api_logging_configuration.py
@@ -5,7 +5,7 @@
from apimatic_core.constants.logger_constants import LoggerConstants
from apimatic_core.logger.default_logger import ConsoleLogger
from apimatic_core.utilities.api_helper import ApiHelper
-
+import copy
class ApiLoggingConfiguration:
@@ -47,6 +47,14 @@ def __init__(self, logger, log_level, mask_sensitive_headers,
self._request_logging_config = request_logging_config
self._response_logging_config = response_logging_config
+ def __deepcopy__(self, memo={}):
+ return ApiLoggingConfiguration(
+ logger=self._logger,
+ log_level=self._log_level,
+ mask_sensitive_headers=self._mask_sensitive_headers,
+ request_logging_config=copy.deepcopy(self._request_logging_config),
+ response_logging_config=copy.deepcopy(self._response_logging_config)
+ )
class BaseHttpLoggingConfiguration:
@@ -178,6 +186,14 @@ def _filter_excluded_headers(self, headers):
extracted_headers[key] = value
return extracted_headers
+ def __deepcopy__(self, memo={}):
+ return self.__class__(
+ log_body=self._log_body,
+ log_headers=self._log_headers,
+ headers_to_include=copy.deepcopy(self._headers_to_include),
+ headers_to_exclude=copy.deepcopy(self._headers_to_exclude),
+ headers_to_unmask=copy.deepcopy(self._headers_to_unmask)
+ )
class ApiRequestLoggingConfiguration(BaseHttpLoggingConfiguration):
@@ -221,6 +237,15 @@ def get_loggable_url(self, query_url):
return ApiHelper.get_url_without_query(query_url)
+ def __deepcopy__(self, memo={}):
+ return ApiRequestLoggingConfiguration(
+ log_body=self._log_body,
+ log_headers=self._log_headers,
+ headers_to_include=copy.deepcopy(self._headers_to_include),
+ headers_to_exclude=copy.deepcopy(self._headers_to_exclude),
+ headers_to_unmask=copy.deepcopy(self._headers_to_unmask),
+ include_query_in_path=self._include_query_in_path
+ )
class ApiResponseLoggingConfiguration(BaseHttpLoggingConfiguration):
diff --git a/apimatic_core/pagination/__init__.py b/apimatic_core/pagination/__init__.py
new file mode 100644
index 0000000..b4fa386
--- /dev/null
+++ b/apimatic_core/pagination/__init__.py
@@ -0,0 +1,5 @@
+__all__ = [
+ 'paginated_data',
+ 'strategies',
+ 'pagination_strategy',
+]
\ No newline at end of file
diff --git a/apimatic_core/pagination/paginated_data.py b/apimatic_core/pagination/paginated_data.py
new file mode 100644
index 0000000..18d6e46
--- /dev/null
+++ b/apimatic_core/pagination/paginated_data.py
@@ -0,0 +1,166 @@
+from apimatic_core.http.http_call_context import HttpCallContext
+from collections.abc import Iterator
+import copy
+
+class PaginatedData(Iterator):
+ """
+ Iterator class for handling paginated API responses.
+
+ Provides methods to iterate over items and pages, fetch next pages using defined pagination strategies,
+ and access the latest HTTP response and request builder. Supports independent iterators for concurrent traversals.
+ """
+
+ @property
+ def last_response(self):
+ """
+ Returns the most recent HTTP response received during pagination.
+ """
+ return self._http_call_context.response if self._last_request_builder is not None else None
+
+ @property
+ def request_builder(self):
+ """
+ Returns the appropriate request builder for the current pagination state.
+ """
+ return self._initial_request_builder if self._last_request_builder is None else self._last_request_builder
+
+
+ @property
+ def page_size(self):
+ """
+ Returns the number of items in the current page of paginated results.
+ """
+ return self._page_size
+
+ def __init__(self, api_call, paginated_items_converter):
+ """
+ Initializes a PaginatedData instance with the given API call and item converter.
+
+ Deep copies the API call, sets up pagination strategies, HTTP call context, and global configuration.
+ Raises:
+ ValueError: If paginated_items_converter is None.
+
+ Args:
+ api_call: The API call object to paginate.
+ paginated_items_converter: Function to convert paginated response bodies into items.
+ """
+ if paginated_items_converter is None:
+ raise ValueError('paginated_items_converter cannot be None')
+
+ self._api_call = api_call
+ self._paginated_items_converter = paginated_items_converter
+ self._initial_request_builder = api_call.request_builder
+ self._last_request_builder = None
+ self._locked_strategy = None
+ self._pagination_strategies = self._api_call.get_pagination_strategies
+ self._http_call_context =\
+ self._api_call.global_configuration.get_http_client_configuration().http_callback or HttpCallContext()
+ _http_client_configuration = self._api_call.global_configuration.get_http_client_configuration().clone(
+ http_callback=self._http_call_context)
+ self._global_configuration = self._api_call.global_configuration.clone_with(
+ http_client_configuration=_http_client_configuration)
+ self._paged_response = None
+ self._items = []
+ self._page_size = 0
+ self._current_index = 0
+
+ def __iter__(self):
+ """
+ Returns a new independent iterator instance for paginated data traversal.
+ """
+ return self.clone()
+
+ def __next__(self):
+ """
+ Returns the next item in the paginated data sequence.
+
+ Fetches the next page if the current page is exhausted. Raises StopIteration when no more items are available.
+ """
+ if self._current_index < self.page_size:
+ item = self._items[self._current_index]
+ self._current_index += 1
+ return item
+
+ self._paged_response = self._fetch_next_page()
+ self._items = self._paginated_items_converter(
+ self._paged_response.body) if self._paged_response else []
+ if not self._items:
+ raise StopIteration
+ self._page_size, self._current_index = len(self._items), 0
+ item = self._items[self._current_index]
+ self._current_index += 1
+ return item
+
+ def pages(self):
+ """
+ Yields each page of the paginated response as an independent generator.
+
+ Returns:
+ Generator yielding HTTP response objects for each page.
+ """
+ # Create a new instance so the page iteration is independent
+ paginated_data = self.clone()
+
+ while True:
+ paginated_data._paged_response = paginated_data._fetch_next_page()
+ paginated_data._items = self._paginated_items_converter(
+ paginated_data._paged_response.body) if paginated_data._paged_response else []
+ if not paginated_data._items:
+ break
+ paginated_data._page_size = len(paginated_data._items)
+ yield paginated_data._paged_response
+
+ def _fetch_next_page(self):
+ """
+ Fetches the next page of paginated data using available pagination strategies.
+
+ Attempts each strategy to build the next request, executes the API call,
+ and applies any response metadata wrappers.
+ Returns an empty list if no further pages are available.
+
+ Returns:
+ The processed response object for the next page, or None if no pagination strategy is applicable.
+
+ Raises:
+ Exception: Propagates any exceptions encountered during the API call execution.
+ """
+
+ if self._locked_strategy is not None:
+ return self._execute_strategy(self._locked_strategy)
+
+ for pagination_strategy in self._pagination_strategies:
+ response = self._execute_strategy(pagination_strategy)
+ if response is None:
+ continue
+
+ if self._locked_strategy is None:
+ self._locked_strategy = self._get_locked_strategy()
+
+ return response
+
+ return None
+
+ def _execute_strategy(self, pagination_strategy):
+ request_builder = pagination_strategy.apply(self)
+ if request_builder is None:
+ return None
+
+ self._last_request_builder = request_builder
+
+ response = self._api_call.clone(
+ global_configuration=self._global_configuration, request_builder=request_builder
+ ).execute()
+ return pagination_strategy.apply_metadata_wrapper(response)
+
+ def _get_locked_strategy(self):
+ for pagination_strategy in self._pagination_strategies:
+ if pagination_strategy.is_applicable(self.last_response):
+ return pagination_strategy
+
+ def clone(self):
+ """
+ Creates and returns a new independent PaginatedData instance
+ with a cloned API call that resets to the initial request builder.
+ """
+ cloned_api_call = self._api_call.clone(request_builder=self._initial_request_builder)
+ return PaginatedData(cloned_api_call, self._paginated_items_converter)
diff --git a/apimatic_core/pagination/pagination_strategy.py b/apimatic_core/pagination/pagination_strategy.py
new file mode 100644
index 0000000..e685a8d
--- /dev/null
+++ b/apimatic_core/pagination/pagination_strategy.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+from abc import ABC, abstractmethod
+
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class PaginationStrategy(ABC):
+ """
+ Abstract base class for implementing pagination strategies.
+
+ Provides methods to initialize with pagination metadata, apply pagination logic to request builders,
+ and update request builders with new pagination parameters based on JSON pointers.
+ """
+
+ PATH_PARAMS_IDENTIFIER = "$request.path"
+ QUERY_PARAMS_IDENTIFIER = "$request.query"
+ HEADER_PARAMS_IDENTIFIER = "$request.headers"
+ BODY_PARAM_IDENTIFIER = "$request.body"
+
+ def __init__(self, metadata_wrapper):
+ """
+ Initializes the PaginationStrategy with the provided metadata wrapper.
+
+ Args:
+ metadata_wrapper: An object containing pagination metadata. Must not be None.
+
+ Raises:
+ ValueError: If metadata_wrapper is None.
+ """
+ if metadata_wrapper is None:
+ raise ValueError("Metadata wrapper for the pagination cannot be None")
+
+ self._metadata_wrapper = metadata_wrapper
+
+ @abstractmethod
+ def is_applicable(self, response):
+ """
+ Checks whether the pagination strategy is a valid candidate based on the given HTTP response.
+
+ Args:
+ response: The response from the previous API call.
+
+ Returns:
+ bool: True if this strategy is valid based on the given HTTP response..
+ """
+ ...
+
+ @abstractmethod
+ def apply(self, paginated_data):
+ """
+ Modifies the request builder to fetch the next page of results based on the provided paginated data.
+
+ Args:
+ paginated_data: The response data from the previous API call.
+
+ Returns:
+ RequestBuilder: An updated request builder configured for the next page request.
+ """
+ ...
+
+ @abstractmethod
+ def apply_metadata_wrapper(self, paged_response):
+ """
+ Processes the paged API response using the metadata wrapper.
+
+ Args:
+ paged_response: The response object containing paginated data.
+
+ Returns:
+ The processed response with applied pagination metadata.
+ """
+ ...
+
+ @staticmethod
+ def get_updated_request_builder(request_builder, input_pointer, offset):
+ """
+ Updates the given request builder by modifying its path, query,
+ or header parameters based on the specified JSON pointer and offset.
+
+ Args:
+ request_builder: The request builder instance to update.
+ input_pointer (str): JSON pointer indicating which parameter to update.
+ offset: The value to set at the specified parameter location.
+
+ Returns:
+ The updated request builder with the modified parameter.
+ """
+ path_prefix, field_path = ApiHelper.split_into_parts(input_pointer)
+ template_params = request_builder.template_params
+ query_params = request_builder.query_params
+ header_params = request_builder.header_params
+ body_params = request_builder.body_params
+ form_params = request_builder.form_params
+
+ if path_prefix == PaginationStrategy.PATH_PARAMS_IDENTIFIER:
+ template_params = ApiHelper.update_entry_by_json_pointer(
+ template_params.copy(), f"{field_path}/value", offset, inplace=True)
+ elif path_prefix == PaginationStrategy.QUERY_PARAMS_IDENTIFIER:
+ query_params = ApiHelper.update_entry_by_json_pointer(
+ query_params.copy(), field_path, offset, inplace=True)
+ elif path_prefix == PaginationStrategy.HEADER_PARAMS_IDENTIFIER:
+ header_params = ApiHelper.update_entry_by_json_pointer(
+ header_params.copy(), field_path, offset, inplace=True)
+ elif path_prefix == PaginationStrategy.BODY_PARAM_IDENTIFIER:
+ if body_params is not None:
+ body_params = ApiHelper.update_entry_by_json_pointer(
+ body_params.copy(), field_path, offset, inplace=True)
+ else:
+ form_params = ApiHelper.update_entry_by_json_pointer(
+ form_params.copy(), field_path, offset, inplace=True)
+
+ return request_builder.clone_with(
+ template_params=template_params, query_params=query_params, header_params=header_params,
+ body_param=body_params, form_params=form_params
+ )
+
+ @staticmethod
+ def _get_initial_request_param_value(request_builder, input_pointer, default=0):
+ """
+ Extracts the initial pagination offset value from the request builder using the specified JSON pointer.
+
+ Args:
+ request_builder: The request builder containing path, query, and header parameters.
+ input_pointer (str): JSON pointer indicating which parameter to extract.
+ default (int, optional): The value to return if the parameter is not found. Defaults to 0.
+
+ Returns:
+ int: The initial offset value from the specified parameter, or default if not found.
+ """
+ path_prefix, field_path = ApiHelper.split_into_parts(input_pointer)
+
+ if path_prefix == PaginationStrategy.PATH_PARAMS_IDENTIFIER:
+ value = ApiHelper.get_value_by_json_pointer(
+ request_builder.template_params, f"{field_path}/value")
+ return int(value) if value is not None else default
+ elif path_prefix == PaginationStrategy.QUERY_PARAMS_IDENTIFIER:
+ value = ApiHelper.get_value_by_json_pointer(request_builder.query_params, field_path)
+ return int(value) if value is not None else default
+ elif path_prefix == PaginationStrategy.HEADER_PARAMS_IDENTIFIER:
+ value = ApiHelper.get_value_by_json_pointer(request_builder.header_params, field_path)
+ return int(value) if value is not None else default
+ elif path_prefix == PaginationStrategy.BODY_PARAM_IDENTIFIER:
+ value = ApiHelper.get_value_by_json_pointer(
+ request_builder.body_params or request_builder.form_params, field_path)
+ return int(value) if value is not None else default
+
+ return default
\ No newline at end of file
diff --git a/apimatic_core/pagination/strategies/__init__.py b/apimatic_core/pagination/strategies/__init__.py
new file mode 100644
index 0000000..99ca3fb
--- /dev/null
+++ b/apimatic_core/pagination/strategies/__init__.py
@@ -0,0 +1,6 @@
+__all__ = [
+ 'page_pagination',
+ 'cursor_pagination',
+ 'link_pagination',
+ 'offset_pagination'
+]
\ No newline at end of file
diff --git a/apimatic_core/pagination/strategies/cursor_pagination.py b/apimatic_core/pagination/strategies/cursor_pagination.py
new file mode 100644
index 0000000..c40aa4d
--- /dev/null
+++ b/apimatic_core/pagination/strategies/cursor_pagination.py
@@ -0,0 +1,132 @@
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class CursorPagination(PaginationStrategy):
+ """
+ Implements a cursor-based pagination strategy for API responses.
+
+ This class manages the extraction and injection of cursor values between API requests and responses,
+ enabling seamless traversal of paginated data. It validates required pointers, updates the request builder
+ with the appropriate cursor, and applies a metadata wrapper to paged responses.
+ """
+
+ def __init__(self, output, input_, metadata_wrapper):
+ """
+ Initializes a CursorPagination instance with the specified output and input pointers and a metadata wrapper.
+
+ Validates that both input and output pointers are provided,
+ and sets up internal state for cursor-based pagination.
+
+ Args:
+ output: JSON pointer to extract the cursor from the API response.
+ input_: JSON pointer indicating where to set the cursor in the request.
+ metadata_wrapper: Function to wrap paged responses with additional metadata.
+
+ Raises:
+ ValueError: If either input_ or output is None.
+ """
+ super().__init__(metadata_wrapper)
+
+ if input_ is None:
+ raise ValueError("Input pointer for cursor based pagination cannot be None")
+ if output is None:
+ raise ValueError("Output pointer for cursor based pagination cannot be None")
+
+ self._output = output
+ self._input = input_
+ self._cursor_value = None
+
+ def is_applicable(self, response):
+ """
+ Checks whether the cursor pagination strategy is a valid candidate based on the given HTTP response.
+
+ Args:
+ response: The response from the previous API call.
+
+ Returns:
+ bool: True if this strategy is valid based on the given HTTP response..
+ """
+ if response is None:
+ return True
+
+ self._cursor_value = ApiHelper.resolve_response_pointer(
+ self._output,
+ response.text,
+ response.headers
+ )
+
+ return self._cursor_value is not None
+
+ def apply(self, paginated_data):
+ """
+ Advances the pagination by updating the request builder with the next cursor value.
+
+ If there is no previous response, initializes the cursor from the request builder.
+ Otherwise, extracts the cursor from the last response using the configured output pointer,
+ and updates the request builder for the next page. Returns None if no further pages are available.
+
+ Args:
+ paginated_data: An object containing the last response and the current request builder.
+
+ Returns:
+ A new request builder for the next page, or None if pagination is complete.
+ """
+ last_response = paginated_data.last_response
+ request_builder = paginated_data.request_builder
+ self._cursor_value = self._get_initial_cursor_value(request_builder, self._input)
+
+ # The last response is none which means this is going to be the 1st page
+ if last_response is None:
+ return request_builder
+
+ self._cursor_value = ApiHelper.resolve_response_pointer(
+ self._output,
+ last_response.text,
+ last_response.headers
+ )
+
+ if self._cursor_value is None:
+ return None
+
+ return self.get_updated_request_builder(request_builder, self._input, self._cursor_value)
+
+ def apply_metadata_wrapper(self, paged_response):
+ """
+ Applies the configured metadata wrapper to the paged response, including the current cursor value.
+
+ Args:
+ paged_response: The response object from the current page.
+
+ Returns:
+ The result of the metadata wrapper applied to the paged response and cursor value.
+ """
+ return self._metadata_wrapper(paged_response, self._cursor_value)
+
+ @staticmethod
+ def _get_initial_cursor_value(request_builder, input_pointer):
+ """
+ Retrieves the initial cursor value from the request builder using the specified input pointer.
+
+ Args:
+ request_builder: The request builder containing request parameters.
+ input_pointer (str): The JSON pointer indicating the location of the cursor value.
+
+ Returns:
+ The initial cursor value if found, otherwise None.
+ """
+ path_prefix, field_path = ApiHelper.split_into_parts(input_pointer)
+
+ if path_prefix == PaginationStrategy.PATH_PARAMS_IDENTIFIER:
+ value = ApiHelper.get_value_by_json_pointer(
+ request_builder.template_params, f"{field_path}/value")
+ return value if value is not None else None
+ elif path_prefix == PaginationStrategy.QUERY_PARAMS_IDENTIFIER:
+ return ApiHelper.get_value_by_json_pointer(request_builder.query_params, field_path)
+ elif path_prefix == PaginationStrategy.HEADER_PARAMS_IDENTIFIER:
+ return ApiHelper.get_value_by_json_pointer(request_builder.header_params, field_path)
+ elif path_prefix == PaginationStrategy.BODY_PARAM_IDENTIFIER:
+ return ApiHelper.get_value_by_json_pointer(
+ request_builder.body_params or request_builder.form_params, field_path)
+
+ return None
diff --git a/apimatic_core/pagination/strategies/link_pagination.py b/apimatic_core/pagination/strategies/link_pagination.py
new file mode 100644
index 0000000..aa18d86
--- /dev/null
+++ b/apimatic_core/pagination/strategies/link_pagination.py
@@ -0,0 +1,98 @@
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.types.parameter import Parameter
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class LinkPagination(PaginationStrategy):
+ """
+ Implements a pagination strategy that extracts the next page link from API responses using a JSON pointer.
+
+ This class updates the request builder with query parameters from the next page link and applies a metadata
+ wrapper to the paged response.
+ """
+
+ def __init__(self, next_link_pointer, metadata_wrapper):
+ """
+ Initializes a LinkPagination instance with the given next link pointer and metadata wrapper.
+
+ Args:
+ next_link_pointer: JSON pointer to extract the next page link from the API response.
+ metadata_wrapper: Callable to wrap the paged response metadata.
+
+ Raises:
+ ValueError: If next_link_pointer is None.
+ """
+ super().__init__(metadata_wrapper)
+
+ if next_link_pointer is None:
+ raise ValueError("Next link pointer for cursor based pagination cannot be None")
+
+ self._next_link_pointer = next_link_pointer
+ self._next_link = None
+
+ def is_applicable(self, response):
+ """
+ Checks whether the link pagination strategy is a valid candidate based on the given HTTP response.
+
+ Args:
+ response: The response from the previous API call.
+
+ Returns:
+ bool: True if this strategy is valid based on the given HTTP response..
+ """
+ if response is None:
+ return True
+
+ self._next_link = ApiHelper.resolve_response_pointer(
+ self._next_link_pointer,
+ response.text,
+ response.headers
+ )
+
+ return self._next_link is not None
+
+ def apply(self, paginated_data):
+ """
+ Updates the request builder with query parameters from the next page
+ link extracted from the last API response.
+
+ Args:
+ paginated_data: An object containing the last API response and the current request builder.
+
+ Returns:
+ A new request builder instance with updated query parameters for the next page,
+ or None if no next link is found.
+ """
+ last_response = paginated_data.last_response
+ request_builder = paginated_data.request_builder
+
+ # The last response is none which means this is going to be the 1st page
+ if last_response is None:
+ return request_builder
+
+ self._next_link = ApiHelper.resolve_response_pointer(
+ self._next_link_pointer,
+ last_response.text,
+ last_response.headers
+ )
+
+ if self._next_link is None:
+ return None
+
+ query_params = ApiHelper.get_query_parameters(self._next_link)
+ updated_query_params = request_builder.query_params.copy()
+ updated_query_params.update(query_params)
+
+ return request_builder.clone_with(query_params=updated_query_params)
+
+ def apply_metadata_wrapper(self, paged_response):
+ """
+ Applies the metadata wrapper to the paged response, including the next page link.
+
+ Args:
+ paged_response: The API response object for the current page.
+
+ Returns:
+ The result of the metadata wrapper, typically containing the response and next link information.
+ """
+ return self._metadata_wrapper(paged_response, self._next_link)
diff --git a/apimatic_core/pagination/strategies/offset_pagination.py b/apimatic_core/pagination/strategies/offset_pagination.py
new file mode 100644
index 0000000..1a2ddef
--- /dev/null
+++ b/apimatic_core/pagination/strategies/offset_pagination.py
@@ -0,0 +1,81 @@
+from apimatic_core.pagination.paginated_data import PaginatedData
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class OffsetPagination(PaginationStrategy):
+ """
+ Implements offset-based pagination strategy for API responses.
+
+ This class manages pagination by updating an offset parameter in the request builder,
+ allowing sequential retrieval of paginated data. It extracts and updates the offset
+ based on a configurable JSON pointer and applies a metadata wrapper to each page response.
+ """
+
+ def __init__(self, input_, metadata_wrapper):
+ """
+ Initializes an OffsetPagination instance with the given input pointer and metadata wrapper.
+
+ Args:
+ input_: JSON pointer indicating the pagination parameter to update.
+ metadata_wrapper: Callable for handling pagination metadata.
+
+ Raises:
+ ValueError: If input_ is None.
+ """
+ super().__init__(metadata_wrapper)
+
+ if input_ is None:
+ raise ValueError("Input pointer for offset based pagination cannot be None")
+
+ self._input = input_
+ self._offset = 0
+
+ def is_applicable(self, response):
+ """
+ Checks whether the offset pagination strategy is a valid candidate based on the given HTTP response.
+
+ Args:
+ response: The response from the previous API call.
+
+ Returns:
+ bool: True if this strategy is valid based on the given HTTP response..
+ """
+ return True
+
+ def apply(self, paginated_data):
+ """
+ Updates the request builder to fetch the next page of results using offset-based pagination.
+
+ If this is the first page, initializes the offset from the request builder. Otherwise,
+ increments the offset by the previous page size and updates the pagination parameter.
+
+ Args:
+ paginated_data: The PaginatedData instance containing the last response, request builder, and page size.
+
+ Returns:
+ An updated request builder configured for the next page request.
+ """
+ last_response = paginated_data.last_response
+ request_builder = paginated_data.request_builder
+ self._offset = self._get_initial_request_param_value(request_builder, self._input)
+
+ # The last response is none which means this is going to be the 1st page
+ if last_response is None:
+ return request_builder
+
+ self._offset += paginated_data.page_size
+
+ return self.get_updated_request_builder(request_builder, self._input, self._offset)
+
+ def apply_metadata_wrapper(self, page_response):
+ """
+ Applies the metadata wrapper to the given page response, passing the current offset.
+
+ Args:
+ page_response: The response object for the current page.
+
+ Returns:
+ The result of the metadata wrapper callable with the page response and offset.
+ """
+ return self._metadata_wrapper(page_response, self._offset)
diff --git a/apimatic_core/pagination/strategies/page_pagination.py b/apimatic_core/pagination/strategies/page_pagination.py
new file mode 100644
index 0000000..1cd6516
--- /dev/null
+++ b/apimatic_core/pagination/strategies/page_pagination.py
@@ -0,0 +1,76 @@
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.utilities.api_helper import ApiHelper
+
+class PagePagination(PaginationStrategy):
+ """
+ Implements a page-based pagination strategy for API requests.
+
+ This class manages pagination by updating the request builder with the appropriate page number,
+ using a JSON pointer to identify the pagination parameter. It also applies a metadata wrapper
+ to each paged response, including the current page number.
+ """
+
+ def __init__(self, input_, metadata_wrapper):
+ """
+ Initializes a PagePagination instance with the given input pointer and metadata wrapper.
+
+ Args:
+ input_: The JSON pointer indicating the pagination parameter in the request.
+ metadata_wrapper: A callable for wrapping pagination metadata.
+
+ Raises:
+ ValueError: If input_ is None.
+ """
+ super().__init__(metadata_wrapper)
+
+ if input_ is None:
+ raise ValueError("Input pointer for page based pagination cannot be None")
+
+ self._input = input_
+ self._page_number = 1
+
+ def is_applicable(self, response):
+ """
+ Checks whether the offset pagination strategy is a valid candidate based on the given HTTP response.
+
+ Args:
+ response: The response from the previous API call.
+
+ Returns:
+ bool: True if this strategy is valid based on the given HTTP response..
+ """
+ return True
+
+ def apply(self, paginated_data):
+ """
+ Updates the request builder to fetch the next page of results based on the current paginated data.
+
+ Args:
+ paginated_data: An object containing the last response, request builder, and page size.
+
+ Returns:
+ The updated request builder configured for the next page request.
+ """
+ last_response = paginated_data.last_response
+ request_builder = paginated_data.request_builder
+ self._page_number = self._get_initial_request_param_value(request_builder, self._input, 1)
+
+ # The last response is none which means this is going to be the 1st page
+ if last_response is None:
+ return request_builder
+
+ self._page_number += 1 if paginated_data.page_size > 0 else 0
+
+ return self.get_updated_request_builder(request_builder, self._input, self._page_number)
+
+ def apply_metadata_wrapper(self, paged_response):
+ """
+ Applies the metadata wrapper to the paged response, including the current page number.
+
+ Args:
+ paged_response: The response object for the current page.
+
+ Returns:
+ The result of the metadata wrapper with the paged response and current page number.
+ """
+ return self._metadata_wrapper(paged_response, self._page_number)
diff --git a/apimatic_core/request_builder.py b/apimatic_core/request_builder.py
index 64b6687..a16298b 100644
--- a/apimatic_core/request_builder.py
+++ b/apimatic_core/request_builder.py
@@ -1,3 +1,5 @@
+from requests.structures import CaseInsensitiveDict
+import copy
from apimatic_core.exceptions.auth_validation_exception import AuthValidationException
from apimatic_core.http.request.http_request import HttpRequest
from apimatic_core.types.array_serialization_format import SerializationFormats
@@ -12,15 +14,34 @@ def get_param_name(param_value):
return None
return param_value.name
+ @property
+ def template_params(self):
+ return self._template_params
+
+ @property
+ def header_params(self):
+ return self._header_params
+
+ @property
+ def query_params(self):
+ return self._query_params
+
+ @property
+ def body_params(self):
+ return self._body_param
+
+ @property
+ def form_params(self):
+ return self._form_params
+
def __init__(
self
):
-
self._server = None
self._path = None
self._http_method = None
self._template_params = {}
- self._header_params = {}
+ self._header_params = CaseInsensitiveDict()
self._query_params = {}
self._form_params = {}
self._additional_form_params = {}
@@ -107,10 +128,10 @@ def xml_attributes(self, xml_attributes):
def build(self, global_configuration):
_url = self.process_url(global_configuration)
- _request_headers = self.process_request_headers(global_configuration)
-
_request_body = self.process_body_params()
+ _request_headers = self.process_request_headers(global_configuration)
+
_multipart_params = self.process_multipart_params()
http_request = HttpRequest(http_method=self._http_method,
@@ -147,14 +168,19 @@ def process_request_headers(self, global_configuration):
additional_headers = global_configuration.get_additional_headers()
if global_headers:
- prepared_headers = {key: str(value) if value is not None else value
- for key, value in self._header_params.items()}
- request_headers = {**global_headers, **prepared_headers}
+ request_headers = {**global_headers, **self._header_params}
if additional_headers:
request_headers.update(additional_headers)
- return request_headers
+ serialized_headers = CaseInsensitiveDict(
+ {
+ key: ApiHelper.json_serialize(value)
+ if value is not None else value
+ for key, value in request_headers.items()
+ }
+ )
+ return serialized_headers
def process_body_params(self):
if self._xml_attributes:
@@ -210,3 +236,57 @@ def apply_auth(self, auth_managers, http_request):
self._auth.apply(http_request)
else:
raise AuthValidationException(self._auth.error_message)
+
+ def clone_with(
+ self, template_params=None, header_params=None, query_params=None,
+ body_param=None, form_params=None
+ ):
+ """
+ Clone the current instance with the given parameters.
+
+ Args:
+ template_params (dict, optional): The template parameters. Defaults to None.
+ header_params (dict, optional): The header parameters. Defaults to None.
+ query_params (dict, optional): The query parameters. Defaults to None.
+ body_param (dict, optional): The body parameters. Defaults to None.
+ form_params (dict, optional): The form parameters. Defaults to None.
+
+ Returns:
+ RequestBuilder: A new instance of the RequestBuilder class with the given parameters.
+ """
+ new_instance = copy.deepcopy(self)
+ new_instance._server = self._server
+ new_instance._path = self._path
+ new_instance._http_method = self._http_method
+ new_instance._template_params = template_params or self._template_params
+ new_instance._header_params = header_params or self._header_params
+ new_instance._query_params = query_params or self._query_params
+ new_instance._form_params = form_params or self._form_params
+ new_instance._additional_form_params = self._additional_form_params
+ new_instance._additional_query_params = self._additional_query_params
+ new_instance._multipart_params = self._multipart_params
+ new_instance._body_param = body_param or self._body_param
+ new_instance._body_serializer = self._body_serializer
+ new_instance._auth = self._auth
+ new_instance._array_serialization_format = self._array_serialization_format
+ new_instance._xml_attributes = self._xml_attributes
+ return new_instance
+
+ def __deepcopy__(self, memodict={}):
+ copy_instance = RequestBuilder()
+ copy_instance._server = copy.deepcopy(self._server, memodict)
+ copy_instance._path = copy.deepcopy(self._path, memodict)
+ copy_instance._http_method = copy.deepcopy(self._http_method, memodict)
+ copy_instance._template_params = copy.deepcopy(self._template_params, memodict)
+ copy_instance._header_params = copy.deepcopy(self._header_params, memodict)
+ copy_instance._query_params = copy.deepcopy(self._query_params, memodict)
+ copy_instance._form_params = copy.deepcopy(self._form_params, memodict)
+ copy_instance._additional_form_params = copy.deepcopy(self._additional_form_params, memodict)
+ copy_instance._additional_query_params = copy.deepcopy(self._additional_query_params, memodict)
+ copy_instance._multipart_params = copy.deepcopy(self._multipart_params, memodict)
+ copy_instance._body_param = copy.deepcopy(self._body_param, memodict)
+ copy_instance._body_serializer = copy.deepcopy(self._body_serializer, memodict)
+ copy_instance._auth = copy.deepcopy(self._auth, memodict)
+ copy_instance._array_serialization_format = copy.deepcopy(self._array_serialization_format, memodict)
+ copy_instance._xml_attributes = copy.deepcopy(self._xml_attributes, memodict)
+ return copy_instance
\ No newline at end of file
diff --git a/apimatic_core/security/__init__.py b/apimatic_core/security/__init__.py
new file mode 100644
index 0000000..df681cb
--- /dev/null
+++ b/apimatic_core/security/__init__.py
@@ -0,0 +1,3 @@
+__all__=[
+ 'signature_verifiers'
+]
\ No newline at end of file
diff --git a/apimatic_core/security/signature_verifiers/__init__.py b/apimatic_core/security/signature_verifiers/__init__.py
new file mode 100644
index 0000000..f6b7c68
--- /dev/null
+++ b/apimatic_core/security/signature_verifiers/__init__.py
@@ -0,0 +1,3 @@
+__all__=[
+ 'hmac_signature_verifier',
+]
\ No newline at end of file
diff --git a/apimatic_core/security/signature_verifiers/hmac_signature_verifier.py b/apimatic_core/security/signature_verifiers/hmac_signature_verifier.py
new file mode 100644
index 0000000..c93a450
--- /dev/null
+++ b/apimatic_core/security/signature_verifiers/hmac_signature_verifier.py
@@ -0,0 +1,113 @@
+import hmac
+import hashlib
+from typing import Callable, Optional, Union
+
+from apimatic_core_interfaces.http.request import Request
+from apimatic_core_interfaces.security.signature_verifier import SignatureVerifier
+from apimatic_core_interfaces.types.signature_verification_result import SignatureVerificationResult
+
+
+class DigestEncoder:
+ """Minimal encoder interface for HMAC digests."""
+ def encode(self, digest: bytes) -> str: # pragma: no cover - interface
+ raise NotImplementedError
+
+
+class HexEncoder(DigestEncoder):
+ """Lowercase hexadecimal encoding."""
+ def encode(self, digest: bytes) -> str:
+ return digest.hex()
+
+
+class Base64Encoder(DigestEncoder):
+ """Standard Base64 encoding."""
+ def encode(self, digest: bytes) -> str:
+ import base64
+ return base64.b64encode(digest).decode("utf-8")
+
+
+class Base64UrlEncoder(DigestEncoder):
+ """URL-safe Base64 without '=' padding."""
+ def encode(self, digest: bytes) -> str:
+ import base64
+ return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
+
+
+class HmacSignatureVerifier(SignatureVerifier):
+ """
+ HMAC signature verifier that delegates message construction to a user-supplied callable.
+
+ Parameters
+ ----------
+ secret_key : str
+ Shared secret used for HMAC.
+ signature_header : str
+ Header name containing the provided signature (case-insensitive lookup).
+ canonical_message_builder : Optional[Callable[[Request], Union[bytes, str, None]]]
+ Function that produces the exact message to sign. If omitted (None) or returns None,
+ the verifier will use request.raw_body (bytes) if present, otherwise request.body (text, UTF-8).
+ hash_alg : Callable (defaults to hashlib.sha256)
+ Hash algorithm for HMAC.
+ encoder : DigestEncoder (defaults to HexEncoder())
+ Encoder to transform HMAC digest bytes into a string for comparison.
+ signature_value_template : Optional[str]
+ If provided, wraps/defines the expected signature. If it contains '{digest}', the
+ placeholder is replaced with the encoded digest; otherwise it is treated as a constant.
+ """
+
+ def __init__(
+ self,
+ *,
+ secret_key: str,
+ signature_header: str,
+ canonical_message_builder: Optional[Callable[[Request], Union[bytes, None]]] = None,
+ hash_alg=hashlib.sha256,
+ encoder: Optional[DigestEncoder] = HexEncoder(),
+ signature_value_template: Optional[str] = '{digest}',
+ ) -> None:
+ if not isinstance(secret_key, str) or not secret_key:
+ raise ValueError("key must be a non-empty string")
+ if not isinstance(signature_header, str) or not signature_header.strip():
+ raise ValueError("signature_header must be a non-empty string")
+
+ self._secret_key = secret_key
+ self._signature_header_lc = signature_header.lower().strip()
+ self._message_resolver = canonical_message_builder
+ self._hash_alg = hash_alg
+ self._encoder = encoder
+ self._signature_value_template = signature_value_template
+
+ def verify(self, request: Request) -> SignatureVerificationResult:
+ try:
+ provided = self._read_signature_header(request)
+ if provided is None:
+ return SignatureVerificationResult.failed(
+ [f"Signature header '{self._signature_header_lc}' is missing"]
+ )
+
+ message_bytes = self._resolve_message_bytes(request)
+ digest = hmac.new(self._secret_key.encode("utf-8"), message_bytes, self._hash_alg).digest()
+ encoded_digest = self._encoder.encode(digest)
+ expected = self._signature_value_template.replace("{digest}", encoded_digest)
+
+ is_match = hmac.compare_digest(provided, expected)
+ return SignatureVerificationResult.passed() if is_match else SignatureVerificationResult.failed(
+ ["Signature mismatch"]
+ )
+ except Exception as exc:
+ return SignatureVerificationResult.failed(
+ [f"Signature Verification Failed: {exc}"]
+ )
+
+ # ------------- internal helpers -------------
+
+ def _read_signature_header(self, request: Request) -> Optional[str]:
+ headers = {str(k).lower(): str(v) for k, v in (getattr(request, "headers", {}) or {}).items()}
+ value = headers.get(self._signature_header_lc)
+ return None if value is None or value.strip() == "" else value
+
+ def _resolve_message_bytes(self, request: Request) -> bytes:
+ if self._message_resolver is None:
+ return request.raw_body
+
+ return self._message_resolver(request)
diff --git a/apimatic_core/test.py b/apimatic_core/test.py
new file mode 100644
index 0000000..e69de29
diff --git a/apimatic_core/utilities/api_helper.py b/apimatic_core/utilities/api_helper.py
index d74747a..7a4489e 100644
--- a/apimatic_core/utilities/api_helper.py
+++ b/apimatic_core/utilities/api_helper.py
@@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
from collections import abc
import re
+import copy
import datetime
import calendar
import email.utils as eut
from time import mktime
-from urllib.parse import urlsplit
+from urllib.parse import urlsplit, urlparse, parse_qsl
import jsonpickle
import dateutil.parser
-from jsonpointer import JsonPointerException, resolve_pointer
+from jsonpointer import set_pointer, JsonPointerException, resolve_pointer
from apimatic_core.types.datetime_format import DateTimeFormat
from apimatic_core.types.file_wrapper import FileWrapper
from apimatic_core.types.array_serialization_format import SerializationFormats
@@ -436,6 +437,20 @@ def clean_url(url):
return protocol + query_url + parameters
+ @staticmethod
+ def get_query_parameters(url):
+ """Extracts query parameters from the given URL.
+
+ Args:
+ url (str): The URL string to extract query parameters from.
+
+ Returns:
+ dict: A dictionary of query parameter key-value pairs.
+ """
+ parsed_url = urlparse(url)
+ query_pairs = parse_qsl(parsed_url.query)
+ return dict(query_pairs)
+
@staticmethod
def form_encode_parameters(form_parameters, array_serialization="indexed"):
"""Form encodes a dictionary of form parameters
@@ -722,6 +737,102 @@ def get_additional_properties(dictionary, unboxing_function):
return additional_properties
+ @staticmethod
+ def resolve_response_pointer(pointer, json_body, json_headers):
+ """
+ Resolves a JSON pointer within the response body or headers.
+
+ Args:
+ pointer (str): The JSON pointer string indicating the location in the response.
+ json_body (str): The JSON-serialized response body.
+ json_headers (dict): The response headers as a dictionary.
+
+ Returns:
+ The value found at the specified pointer location, or None if not found or pointer is invalid.
+
+ """
+ if pointer is None or pointer == '':
+ return None
+
+ prefix, path = ApiHelper.split_into_parts(pointer)
+ path = path.rstrip('}')
+
+ try:
+ if prefix == "$response.body":
+ response = ApiHelper.json_deserialize(json_body, as_dict=True)
+ return resolve_pointer(response, path)
+ elif prefix == "$response.headers":
+ return resolve_pointer(json_headers, path)
+ else:
+ return None
+ except JsonPointerException:
+ return None
+
+ @staticmethod
+ def split_into_parts(json_pointer):
+ """
+ Splits a JSON pointer string into its prefix and field path components.
+
+ Args:
+ json_pointer (str): The JSON pointer string to split.
+
+ Returns:
+ tuple: A tuple containing the path prefix and the field path. Returns None if input is None.
+ """
+ if json_pointer is None or json_pointer == '':
+ return None, None
+
+ pointer_parts = json_pointer.split("#")
+ path_prefix = pointer_parts[0]
+ field_path = pointer_parts[1] if len(pointer_parts) > 1 else ""
+
+ return path_prefix, field_path
+
+ @staticmethod
+ def update_entry_by_json_pointer(dictionary, pointer, value, inplace=True):
+ """
+ Update the value at a specified JSON pointer path within a dictionary.
+
+ Args:
+ dictionary (dict): The dictionary to modify.
+ pointer (str): The JSON pointer path indicating where to update the value.
+ value: The new value to set at the specified path.
+ inplace (bool, optional): If True, update the dictionary in place. Defaults to True.
+
+ Returns:
+ dict: The updated dictionary.
+ """
+ if not inplace:
+ dictionary = copy.deepcopy(dictionary)
+
+ parts = pointer.strip("/").split("/")
+ current = dictionary
+ for part in parts[:-1]:
+ if part not in current or not isinstance(current[part], dict):
+ current[part] = {}
+ current = current[part]
+ current[parts[-1]] = value
+ return dictionary
+
+ @staticmethod
+ def get_value_by_json_pointer(dictionary, pointer):
+ """
+ Retrieve a value from a dictionary using a JSON pointer path.
+
+ Args:
+ dictionary (dict): The dictionary to search.
+ pointer (str): The JSON pointer path to the desired value.
+
+ Returns:
+ The value at the specified JSON pointer path.
+
+ Raises:
+ JsonPointerException: If the pointer does not resolve to a value.
+ """
+ try:
+ return resolve_pointer(dictionary, pointer)
+ except JsonPointerException:
+ return None
class CustomDate(object):
diff --git a/requirements.txt b/requirements.txt
index 66f28f5..0b2e612 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
-jsonpickle~=3.0.1, >= 3.0.1
-python-dateutil~=2.8.1
-apimatic-core-interfaces~=0.1.0
+jsonpickle>=3.3, <4.1
+python-dateutil>= 2.9, < 3.0
+apimatic-core-interfaces~=0.1.0, >= 0.1.8
requests~=2.31
setuptools>=68.0.0
-jsonpointer~=2.3
+jsonpointer~=3.0
+typing-extensions~=4.0
diff --git a/setup.py b/setup.py
index 359bf1c..0703d0f 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
setup(
name='apimatic-core',
- version='0.2.16',
+ version='0.2.24',
description='A library that contains core logic and utilities for '
'consuming REST APIs using Python SDKs generated by APIMatic.',
long_description=long_description,
@@ -23,15 +23,18 @@
url='https://github.com/apimatic/core-lib-python',
packages=find_packages(),
install_requires=[
- 'apimatic-core-interfaces~=0.1.0',
- 'jsonpickle~=3.0.1, >= 3.0.1',
- 'python-dateutil~=2.8.1',
+ 'apimatic-core-interfaces~=0.1.0, >= 0.1.8',
+ 'jsonpickle>=3.3, <4.1',
+ 'python-dateutil>= 2.9, < 3.0',
'requests~=2.31',
'setuptools>=68.0.0',
- 'jsonpointer~=2.3'
+ 'jsonpointer~=3.0',
+ 'typing-extensions~=4.0'
],
tests_require=[
- 'pytest~=7.2.2',
- 'pytest-cov~=4.0.0'
+ 'pytest>=7.2.2, <9.1.0',
+ 'coverage>=7.2.2, <7.14.0',
+ 'pytest-cov>=4.0, <7.1',
+ 'testfixtures>=8.3.0, <= 10.0.0'
]
)
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..f9def5a
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,10 @@
+sonar.projectKey=apimatic_core-lib-python
+sonar.projectName=APIMatic Core Library for Python
+sonar.organization=apimatic
+sonar.host.url=https://sonarcloud.io
+sonar.sourceEncoding=UTF-8
+
+sonar.sources=apimatic_core
+sonar.tests=tests
+
+sonar.python.coverage.reportPaths=coverage.xml
diff --git a/test-requirements.txt b/test-requirements.txt
index fed1748..ea32e8e 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,3 +1,4 @@
-pytest~=7.2.2
-coverage~=7.2.2
-testfixtures~=8.2.0
+pytest>=7.2.2, <9.1.0
+pytest-mock~=3.14.0
+coverage>=7.2.2, <7.14.0
+testfixtures>=8.3.0, <= 10.0.0
diff --git a/tests/apimatic_core/__init__.py b/tests/apimatic_core/__init__.py
index 672f182..523c5f2 100644
--- a/tests/apimatic_core/__init__.py
+++ b/tests/apimatic_core/__init__.py
@@ -6,5 +6,6 @@
'mocks',
'api_call_tests',
'api_logger_tests',
- 'union_type_tests'
+ 'union_type_tests',
+ 'pagination_tests'
]
diff --git a/tests/apimatic_core/adapters/__init__.py b/tests/apimatic_core/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/apimatic_core/adapters/test_request_adapter.py b/tests/apimatic_core/adapters/test_request_adapter.py
new file mode 100644
index 0000000..3fb5eb6
--- /dev/null
+++ b/tests/apimatic_core/adapters/test_request_adapter.py
@@ -0,0 +1,414 @@
+import asyncio
+import pytest
+from apimatic_core_interfaces.http.request import Request
+
+from apimatic_core.adapters.request_adapter import (
+ to_unified_request_async, # async core
+ to_unified_request, # sync wrapper
+ _as_listdict, # helper (public in module)
+)
+
+
+# -----------------------
+# Shared duck-typed helpers
+# -----------------------
+
+class MultiDictStub:
+ def __init__(self, mapping):
+ self._m = {k: (list(v) if isinstance(v, (list, tuple)) else [v])
+ for k, v in mapping.items()}
+ def getlist(self, key):
+ return list(self._m.get(key, []))
+ def keys(self):
+ return list(self._m.keys())
+
+
+class MappingStub:
+ """Plain mapping (no getlist) with .keys and __getitem__."""
+ def __init__(self, mapping):
+ self._m = dict(mapping)
+ def keys(self):
+ return list(self._m.keys())
+ def __getitem__(self, k):
+ return self._m[k]
+
+
+class MappingWithNonCallableGetlist(MappingStub):
+ """Has a non-callable attribute named 'getlist' to exercise callable(getlist) == False."""
+ getlist = 42 # not callable
+
+
+# -------- Starlette/FastAPI-like duck types --------
+
+class URLStub:
+ def __init__(self, url: str, path: str):
+ self._url = url
+ self.path = path
+ def __str__(self):
+ return self._url
+
+
+class UploadFileLike:
+ def __init__(self, filename: str, data: bytes):
+ self.filename = filename
+ self._data = data
+ async def read(self):
+ return self._data
+ read = read
+
+
+class FormDataStarletteLike:
+ def __init__(self, mapping):
+ self._m = {k: list(v) for k, v in mapping.items()}
+ def keys(self):
+ return list(self._m.keys())
+ def getlist(self, key):
+ return list(self._m.get(key, []))
+
+
+class StarletteRequestLikeStub:
+ def __init__(
+ self,
+ *,
+ method="POST",
+ url="https://ex.com/submit?a=1&a=2",
+ path="/submit",
+ headers=None,
+ body=b"",
+ query_params=None,
+ cookies=None,
+ formdata=None,
+ ):
+ self.method = method
+ self.url = URLStub(url, path)
+ self.headers = headers or {}
+ self._body = body
+ self.query_params = query_params or {}
+ self.cookies = cookies or {}
+ self._formdata = formdata
+ async def body(self):
+ return bytes(self._body)
+ async def form(self):
+ return self._formdata
+
+
+# -------- Flask/Werkzeug-like duck types --------
+
+class FlaskRequestLikeStub:
+ def __init__(
+ self,
+ *,
+ method="POST",
+ path="/p",
+ url="http://localhost/p?q=x&q=y",
+ headers=None,
+ data=b"payload",
+ args=None,
+ cookies=None,
+ form=None,
+ ):
+ self.method = method
+ self.path = path
+ self.url = url
+ self.headers = headers or {}
+ self._data = data
+ self.args = args or {}
+ self.cookies = cookies or {}
+ self.form = form or {}
+ def get_data(self, cache=True):
+ return bytes(self._data)
+
+
+# -------- Django-like duck types --------
+
+class DjangoRequestLikeStub:
+ def __init__(
+ self,
+ *,
+ method="POST",
+ path="/post",
+ headers=None,
+ meta=None,
+ body=b"",
+ GET=None,
+ COOKIES=None,
+ POST=None,
+ absolute="http://testserver/post?page=1&page=2",
+ ):
+ self.method = method
+ self.path = path
+ self.headers = headers or {}
+ self.META = meta or {}
+ self.body = body
+ self.GET = GET or {}
+ self.COOKIES = COOKIES or {}
+ self.POST = POST or {}
+ self._abs = absolute
+ def build_absolute_uri(self):
+ return self._abs
+
+
+# -------- LocalProxy-like un-wrapper duck type --------
+
+class LocalProxyLike:
+ def __init__(self, target):
+ self._target = target
+ def _get_current_object(self):
+ return self._target
+
+
+class LocalProxyRaising(DjangoRequestLikeStub):
+ """
+ Duck-typed LocalProxy that *has* _get_current_object but it raises.
+ We subclass DjangoRequestLikeStub so that even if unwrapping fails,
+ the returned object still structurally satisfies the Django Protocol,
+ letting the adapter proceed and exercising the 'except' branch.
+ """
+ def _get_current_object(self):
+ raise RuntimeError("boom")
+
+
+# =======================
+# Pytest class wrapper
+# =======================
+
+class TestRequestAdapter:
+
+ # ------- Starlette / FastAPI branch -------
+
+ def test_starlette_non_form_skips_form_and_snapshots_body(self):
+ req = StarletteRequestLikeStub(
+ method="GET",
+ url="https://ex.com/fast-json/42?q=ok",
+ path="/fast-json/42",
+ headers={"Accept": "application/json"},
+ body=b'{"msg":"hello"}',
+ query_params=MultiDictStub({"q": ["ok"]}),
+ cookies={"sid": "abc"},
+ formdata=FormDataStarletteLike({"ignored": ["x"]}),
+ )
+ snap: Request = asyncio.run(to_unified_request_async(req))
+ assert snap.method == "GET"
+ assert snap.path == "/fast-json/42"
+ assert snap.url == "https://ex.com/fast-json/42?q=ok"
+ assert snap.query == {"q": ["ok"]}
+ assert snap.cookies == {"sid": "abc"}
+ assert snap.form == {} # no Content-Type => skip form parsing
+ assert snap.raw_body == b'{"msg":"hello"}'
+
+ def test_starlette_form_multipart_parses_text_and_ignores_files(self):
+ form = FormDataStarletteLike({
+ "user": ["alice", "bob"],
+ "upload": [UploadFileLike("a.bin", b"AAAA")], # should be ignored
+ })
+ req = StarletteRequestLikeStub(
+ method="POST",
+ url="https://ex.com/fast-mp/9?z=ok",
+ path="/fast-mp/9",
+ headers={"Content-Type": "multipart/form-data; boundary=xyz"},
+ body=b"--xyz...",
+ query_params=MultiDictStub({"z": ["ok"]}),
+ cookies={"c": "1"},
+ formdata=form,
+ )
+ snap: Request = asyncio.run(to_unified_request_async(req))
+ assert snap.method == "POST"
+ assert snap.path == "/fast-mp/9"
+ assert snap.query == {"z": ["ok"]}
+ assert snap.cookies == {"c": "1"}
+ assert snap.form == {"user": ["alice", "bob"]}
+ assert "upload" not in snap.form
+
+ def test_starlette_form_urlencoded_parses_scalars_and_lists(self):
+ form = FormDataStarletteLike({
+ "name": ["Sufyan"],
+ "roles": ["admin", "editor"],
+ })
+ req = StarletteRequestLikeStub(
+ method="POST",
+ url="https://ex.com/fast-form/1?x=1",
+ path="/fast-form/1",
+ headers={"content-type": "application/x-www-form-urlencoded"},
+ body=b"a=1",
+ query_params=MultiDictStub({"x": ["1"]}),
+ cookies={"c": "2"},
+ formdata=form,
+ )
+ snap: Request = asyncio.run(to_unified_request_async(req))
+ assert snap.method == "POST"
+ assert snap.path == "/fast-form/1"
+ assert snap.query == {"x": ["1"]}
+ assert snap.cookies == {"c": "2"}
+ assert snap.form == {"name": ["Sufyan"], "roles": ["admin", "editor"]}
+
+ # ------- Flask branch -------
+
+ def test_flask_basic_and_cookie_header_fallback(self):
+ headers = {"X-Test": "1", "Cookie": "sid=abc; theme=light"}
+ args = MultiDictStub({"q": ["x", "y"]})
+ form = MultiDictStub({"name": "Sufyan"})
+ req = FlaskRequestLikeStub(
+ method="POST",
+ path="/flask-form/5",
+ url="http://localhost/flask-form/5?q=x&q=y",
+ headers=headers,
+ data=b"a=1&b=2",
+ args=args,
+ cookies={}, # force header fallback
+ form=form,
+ )
+ snap: Request = to_unified_request(req)
+ assert snap.method == "POST"
+ assert snap.path == "/flask-form/5"
+ assert snap.url.endswith("/flask-form/5?q=x&q=y")
+ assert snap.headers.get("X-Test") == "1"
+ assert snap.query == {"q": ["x", "y"]}
+ assert snap.cookies["sid"] == "abc" and snap.cookies["theme"] == "light"
+ assert snap.form == {"name": ["Sufyan"]}
+ assert snap.raw_body == b"a=1&b=2"
+
+ def test_flask_uses_cookie_jar_when_present_no_header_needed(self):
+ headers = {"x": "y", "cookie": "ignored=1"} # lowercase 'cookie' shouldn't be used
+ req = FlaskRequestLikeStub(
+ method="GET",
+ path="/flask-cookies/1",
+ url="http://localhost/flask-cookies/1",
+ headers=headers,
+ data=b"",
+ args=MultiDictStub({}),
+ cookies={"sid": "JAR"},
+ form=MultiDictStub({}),
+ )
+ snap: Request = to_unified_request(req)
+ assert snap.cookies == {"sid": "JAR"} # header fallback not used
+
+ # ------- Django branch -------
+
+ def test_django_headers_meta_fallback_and_basic_mapping(self):
+ meta = {"HTTP_X_H": "v", "SOME_OTHER": "ignored"}
+ req = DjangoRequestLikeStub(
+ method="POST",
+ path="/django-form/7",
+ headers={}, # triggers META fallback
+ meta=meta,
+ body=b"payload",
+ GET=MultiDictStub({"x": ["1", "2"]}),
+ COOKIES={"csrftoken": "t"},
+ POST=MultiDictStub({"a": "1", "b": "2"}),
+ absolute="http://testserver/django-form/7?x=1&x=2",
+ )
+ snap: Request = to_unified_request(req)
+ assert snap.method == "POST"
+ assert snap.path == "/django-form/7"
+ assert snap.url.startswith("http://testserver/django-form/7?")
+ assert snap.headers.get("X-H") == "v"
+ assert snap.cookies == {"csrftoken": "t"}
+ assert snap.query == {"x": ["1", "2"]}
+ assert snap.form == {"a": ["1"], "b": ["2"]}
+ assert snap.raw_body == b"payload"
+
+ def test_django_headers_present_no_meta_fallback(self):
+ req = DjangoRequestLikeStub(
+ method="GET",
+ path="/h",
+ headers={"X-Direct": "yes"},
+ meta={"HTTP_X_META": "nope"},
+ body=b"",
+ GET=MultiDictStub({"p": "1"}),
+ COOKIES={"c": "v"},
+ POST=MultiDictStub({}),
+ absolute="http://testserver/h?p=1",
+ )
+ snap: Request = to_unified_request(req)
+ assert snap.headers == {"X-Direct": "yes"} # no META fallback used
+
+ # ------- Sync wrapper LocalProxy unwrapping -------
+
+ def test_sync_wrapper_unwraps_localproxy_duck_type(self):
+ inner = DjangoRequestLikeStub(
+ method="GET",
+ path="/p",
+ headers={"X": "1"},
+ body=b"",
+ GET=MultiDictStub({"q": "ok"}),
+ COOKIES={"sid": "abc"},
+ POST=MultiDictStub({}),
+ absolute="http://testserver/p?q=ok",
+ )
+ proxy = LocalProxyLike(inner)
+ snap: Request = to_unified_request(proxy)
+ assert snap.method == "GET"
+ assert snap.path == "/p"
+ assert snap.query == {"q": ["ok"]}
+ assert snap.cookies == {"sid": "abc"}
+
+ def test_sync_wrapper_unwrap_localproxy_raises_and_uses_original_object(self):
+ # This object has a raising _get_current_object(), so _unwrap_local_proxy
+ # should hit the except path and return the object itself. Because the object
+ # also satisfies the Django-like Protocol, the adapter can still convert it.
+ proxy = LocalProxyRaising(
+ method="GET",
+ path="/ex",
+ headers={"X": "1"},
+ body=b"",
+ GET=MultiDictStub({"q": "ok"}),
+ COOKIES={"sid": "abc"},
+ POST=MultiDictStub({}),
+ absolute="http://testserver/ex?q=ok",
+ )
+ snap = to_unified_request(proxy)
+ assert snap.method == "GET"
+ assert snap.path == "/ex"
+ assert snap.query == {"q": ["ok"]}
+ assert snap.cookies == {"sid": "abc"}
+
+ def test_sync_wrapper_plain_object_pass_through(self):
+ # Ensures _unwrap_local_proxy returns obj when there's no _get_current_object
+ req = DjangoRequestLikeStub(
+ method="GET",
+ path="/plain",
+ headers={"A": "B"},
+ body=b"",
+ GET=MultiDictStub({}),
+ COOKIES={},
+ POST=MultiDictStub({}),
+ absolute="http://testserver/plain",
+ )
+ snap = to_unified_request(req)
+ assert snap.path == "/plain"
+ assert snap.headers == {"A": "B"}
+
+ # ------- Error branch -------
+
+ def test_async_adapter_rejects_unsupported_type(self):
+ class Unknown:
+ pass
+ with pytest.raises(TypeError):
+ asyncio.run(to_unified_request_async(Unknown()))
+
+ # ------- _as_listdict coverage -------
+
+ def test_empty_object_returns_empty_dict(self):
+ # Path 1: 'if not obj' β True
+ assert _as_listdict({}) == {}
+ assert _as_listdict(None) == {}
+
+ def test_multidict_path_uses_getlist_and_copies_lists(self):
+ # Path 2: callable(getlist) β True
+ md = MultiDictStub({"a": ["1", "2"], "b": "x"})
+ out = _as_listdict(md)
+ assert out == {"a": ["1", "2"], "b": ["x"]}
+ # ensure we returned *copies*, not the same underlying lists
+ assert out["a"] is not md.getlist("a")
+
+ def test_plain_mapping_path_wraps_scalar_values_in_lists(self):
+ # Path 3: callable(getlist) β False (no getlist attr)
+ mp = MappingStub({"a": "1", "b": "x"})
+ out = _as_listdict(mp)
+ assert out == {"a": ["1"], "b": ["x"]}
+
+ def test_plain_mapping_even_with_noncallable_getlist(self):
+ # Still Path 3: callable(getlist) β False (attr exists but not callable)
+ mp = MappingWithNonCallableGetlist({"k": "v"})
+ out = _as_listdict(mp)
+ assert out == {"k": ["v"]}
diff --git a/tests/apimatic_core/api_call_tests/test_paginated_api_call.py b/tests/apimatic_core/api_call_tests/test_paginated_api_call.py
new file mode 100644
index 0000000..6f7527f
--- /dev/null
+++ b/tests/apimatic_core/api_call_tests/test_paginated_api_call.py
@@ -0,0 +1,256 @@
+from apimatic_core.pagination.strategies.cursor_pagination import CursorPagination
+from apimatic_core.pagination.strategies.link_pagination import LinkPagination
+from apimatic_core.pagination.strategies.offset_pagination import OffsetPagination
+from apimatic_core.pagination.strategies.page_pagination import PagePagination
+from apimatic_core.request_builder import RequestBuilder
+from apimatic_core.response_handler import ResponseHandler
+from apimatic_core.types.parameter import Parameter
+from apimatic_core.utilities.api_helper import ApiHelper
+from apimatic_core_interfaces.types.http_method_enum import HttpMethodEnum
+from tests.apimatic_core.base import Base
+from tests.apimatic_core.mocks.callables.base_uri_callable import Server
+from tests.apimatic_core.mocks.models.transactions_cursored import TransactionsCursored
+from tests.apimatic_core.mocks.models.transactions_linked import TransactionsLinked
+from tests.apimatic_core.mocks.models.transactions_offset import TransactionsOffset
+from tests.apimatic_core.mocks.pagination.paged_iterable import PagedIterable
+from tests.apimatic_core.mocks.pagination.paged_api_response import CursorPagedApiResponse, OffsetPagedApiResponse, \
+ LinkPagedApiResponse, NumberPagedApiResponse
+from tests.apimatic_core.mocks.pagination.paged_response import NumberPagedResponse, OffsetPagedResponse, \
+ CursorPagedResponse, LinkPagedResponse
+
+
+class TestPaginatedApiCall(Base):
+
+ def setup_test(self, global_config):
+ self.global_config = global_config
+ self.http_response_catcher = self.global_config.get_http_client_configuration().http_callback
+ self.http_client = self.global_config.get_http_client_configuration().http_client
+ self.api_call_builder = self.new_api_call_builder(self.global_config)
+
+ def _make_paginated_call(
+ self, path, query_params, pagination_strategy, deserialize_into_model, is_api_response_enabled,
+ body_params=None, form_params=None
+ ):
+ """
+ Helper method to build and execute a paginated API call.
+ """
+
+ if body_params is None:
+ body_params = {}
+
+ if form_params is None:
+ form_params = {}
+
+ response_handler = ResponseHandler() \
+ .deserializer(ApiHelper.json_deserialize) \
+ .deserialize_into(deserialize_into_model)
+
+ if is_api_response_enabled:
+ response_handler.is_api_response(True)
+
+ request_builder = RequestBuilder() \
+ .server(Server.DEFAULT) \
+ .path(path) \
+ .http_method(HttpMethodEnum.GET) \
+ .header_param(Parameter().key('accept').value('application/json'))
+
+ for key, value in query_params.items():
+ request_builder.query_param(Parameter().key(key).value(value))
+
+ for key, value in body_params.items():
+ request_builder.body_param(Parameter().key(key).value(value))
+
+ for key, value in form_params.items():
+ request_builder.form_param(Parameter().key(key).value(value))
+
+ return self.api_call_builder.new_builder.request(request_builder) \
+ .response(response_handler) \
+ .pagination_strategies(pagination_strategy) \
+ .paginate(
+ lambda _paginated_data: PagedIterable(_paginated_data),
+ lambda _response: _response.data
+ )
+
+ def _assert_paginated_results(self, result):
+ """
+ Helper method to assert the common pagination results.
+ """
+ assert isinstance(result, PagedIterable)
+ paginated_data = []
+ for item in result:
+ paginated_data.append(item)
+
+ assert len(paginated_data) == 20
+
+ for page in result.pages():
+ assert len(page.items()) == 5
+
+ # --- Tests with API Response Enabled ---
+
+ def test_link_paginated_call_with_api_response_enabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ size = 5
+ result = self._make_paginated_call(
+ path='/transactions/links',
+ query_params={'page': 1, 'size': size},
+ pagination_strategy=LinkPagination(
+ '$response.body#/links/next',
+ lambda _response, _link: LinkPagedApiResponse.create(
+ _response, lambda _obj: _obj.data, _link)
+ ),
+ deserialize_into_model=TransactionsLinked.from_dictionary,
+ is_api_response_enabled=True
+ )
+ self._assert_paginated_results(result)
+
+ def test_cursor_paginated_call_with_api_response_enabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ limit = 5
+ result = self._make_paginated_call(
+ path='/transactions/cursor',
+ query_params={'cursor': 'initial cursor', 'limit': limit},
+ pagination_strategy=CursorPagination(
+ '$response.body#/nextCursor',
+ '$request.query#/cursor',
+ lambda _response, _cursor: CursorPagedApiResponse.create(
+ _response, lambda _obj: _obj.data, _cursor)
+ ),
+ deserialize_into_model=TransactionsCursored.from_dictionary,
+ is_api_response_enabled=True
+ )
+ self._assert_paginated_results(result)
+
+ def test_offset_paginated_call_with_api_response_enabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ limit = 5
+ result = self._make_paginated_call(
+ path='/transactions/offset',
+ query_params={'offset': 0, 'limit': limit},
+ pagination_strategy=OffsetPagination(
+ '$request.query#/offset',
+ lambda _response, _offset: OffsetPagedApiResponse.create(
+ _response, lambda _obj: _obj.data, _offset)
+ ),
+ deserialize_into_model=TransactionsOffset.from_dictionary,
+ is_api_response_enabled=True
+ )
+ self._assert_paginated_results(result)
+
+ def test_page_paginated_call_with_api_response_enabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ size = 5
+ result = self._make_paginated_call(
+ path='/transactions/page',
+ query_params={'page': 1, 'size': size},
+ pagination_strategy=PagePagination(
+ '$request.query#/page',
+ lambda _response, _page_no: NumberPagedApiResponse.create(
+ _response, lambda _obj: _obj.data, _page_no)
+ ),
+ deserialize_into_model=TransactionsLinked.from_dictionary,
+ is_api_response_enabled=True
+ )
+ self._assert_paginated_results(result)
+
+ # --- Tests with API Response Disabled ---
+
+ def test_link_paginated_call_with_api_response_disabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ size = 5
+ result = self._make_paginated_call(
+ path='/transactions/links',
+ query_params={'page': 1, 'size': size},
+ pagination_strategy=LinkPagination(
+ '$response.body#/links/next',
+ lambda _response, _link: LinkPagedResponse(
+ _response, lambda _obj: _obj.data, _link)
+ ),
+ deserialize_into_model=TransactionsLinked.from_dictionary,
+ is_api_response_enabled=False
+ )
+ self._assert_paginated_results(result)
+
+ def test_cursor_paginated_call_with_api_response_disabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ limit = 5
+ result = self._make_paginated_call(
+ path='/transactions/cursor',
+ query_params={'cursor': 'initial cursor', 'limit': limit},
+ pagination_strategy=CursorPagination(
+ '$response.body#/nextCursor',
+ '$request.query#/cursor',
+ lambda _response, _cursor: CursorPagedResponse(
+ _response, lambda _obj: _obj.data, _cursor)
+ ),
+ deserialize_into_model=TransactionsCursored.from_dictionary,
+ is_api_response_enabled=False
+ )
+ self._assert_paginated_results(result)
+
+ def test_offset_paginated_call_with_api_response_disabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ limit = 5
+ result = self._make_paginated_call(
+ path='/transactions/offset',
+ query_params={'offset': 0, 'limit': limit},
+ pagination_strategy=OffsetPagination(
+ '$request.query#/offset',
+ lambda _response, _offset: OffsetPagedResponse(
+ _response, lambda _obj: _obj.data, _offset)
+ ),
+ deserialize_into_model=TransactionsOffset.from_dictionary,
+ is_api_response_enabled=False
+ )
+ self._assert_paginated_results(result)
+
+ def test_page_paginated_call_with_api_response_disabled(self):
+ self.setup_test(self.paginated_global_configuration)
+ size = 5
+ result = self._make_paginated_call(
+ path='/transactions/page',
+ query_params={'page': 1, 'size': size},
+ pagination_strategy=PagePagination(
+ '$request.query#/page',
+ lambda _response, _page_no: NumberPagedResponse(
+ _response, lambda _obj: _obj.data, _page_no)
+ ),
+ deserialize_into_model=TransactionsLinked.from_dictionary,
+ is_api_response_enabled=False
+ )
+ self._assert_paginated_results(result)
+
+ def test_page_paginated_json_body_call(self):
+ self.setup_test(self.paginated_global_configuration)
+ limit = 5
+ result = self._make_paginated_call(
+ path='/transactions/cursor',
+ query_params={},
+ body_params={'cursor': 'initial cursor', 'limit': limit},
+ pagination_strategy=CursorPagination(
+ '$response.body#/nextCursor',
+ '$request.body#/cursor',
+ lambda _response, _cursor: CursorPagedResponse(
+ _response, lambda _obj: _obj.data, _cursor)
+ ),
+ deserialize_into_model=TransactionsLinked.from_dictionary,
+ is_api_response_enabled=False
+ )
+ self._assert_paginated_results(result)
+
+ def test_page_paginated_form_body_call(self):
+ self.setup_test(self.paginated_global_configuration)
+ limit = 5
+ result = self._make_paginated_call(
+ path='/transactions/cursor',
+ query_params={},
+ form_params={'cursor': 'initial cursor', 'limit': limit},
+ pagination_strategy=CursorPagination(
+ '$response.body#/nextCursor',
+ '$request.body#/cursor',
+ lambda _response, _cursor: CursorPagedResponse(
+ _response, lambda _obj: _obj.data, _cursor)
+ ),
+ deserialize_into_model=TransactionsLinked.from_dictionary,
+ is_api_response_enabled=False
+ )
+ self._assert_paginated_results(result)
\ No newline at end of file
diff --git a/tests/apimatic_core/base.py b/tests/apimatic_core/base.py
index 425eb85..84941e5 100644
--- a/tests/apimatic_core/base.py
+++ b/tests/apimatic_core/base.py
@@ -4,6 +4,7 @@
from datetime import datetime, date
from apimatic_core.api_call import ApiCall
+from apimatic_core.configurations.endpoint_configuration import EndpointConfiguration
from apimatic_core.http.configurations.http_client_configuration import HttpClientConfiguration
from apimatic_core.http.http_callback import HttpCallBack
from apimatic_core.logger.configuration.api_logging_configuration import ApiLoggingConfiguration, \
@@ -24,6 +25,7 @@
from tests.apimatic_core.mocks.exceptions.nested_model_exception import NestedModelException
from tests.apimatic_core.mocks.http.http_response_catcher import HttpResponseCatcher
from tests.apimatic_core.mocks.http.http_client import MockHttpClient
+from tests.apimatic_core.mocks.http.mock_paginated_http_client import MockPaginatedHttpClient
from tests.apimatic_core.mocks.models.cat_model import CatModel
from tests.apimatic_core.mocks.models.complex_type import ComplexType
from tests.apimatic_core.mocks.models.dog_model import DogModel
@@ -206,10 +208,16 @@ def mocked_http_client():
return MockHttpClient()
@staticmethod
- def http_client_configuration(http_callback=HttpResponseCatcher(), logging_configuration=None):
- http_client_configurations = HttpClientConfiguration(http_call_back=http_callback,
- logging_configuration=logging_configuration)
- http_client_configurations.set_http_client(Base.mocked_http_client())
+ def mocked_paginated_http_client():
+ return MockPaginatedHttpClient()
+
+ @staticmethod
+ def http_client_configuration(http_callback=HttpResponseCatcher(), logging_configuration=None, http_client=None):
+ http_client_configurations = HttpClientConfiguration(
+ http_call_back=http_callback,
+ logging_configuration=logging_configuration
+ )
+ http_client_configurations.set_http_client(Base.mocked_http_client() if http_client is None else http_client)
return http_client_configurations
@property
@@ -227,6 +235,14 @@ def global_configuration(self):
.base_uri_executor(BaseUriCallable().get_base_uri) \
.global_errors(self.global_errors())
+ @property
+ def paginated_global_configuration(self):
+ return (GlobalConfiguration(self.http_client_configuration(
+ http_callback=HttpResponseCatcher(),
+ http_client=Base.mocked_paginated_http_client()))
+ .base_uri_executor(BaseUriCallable().get_base_uri)
+ .global_errors(self.global_errors()))
+
@property
def global_configuration_without_http_callback(self):
return GlobalConfiguration(self.http_client_configuration(None)) \
@@ -324,3 +340,6 @@ def get_union_type_scalar_model():
one_of_req_nullable='abc',
one_of_optional=200,
any_of_opt_nullable=True)
+ @staticmethod
+ def endpoint_configuration():
+ return EndpointConfiguration
\ No newline at end of file
diff --git a/tests/apimatic_core/configuration/__init__.py b/tests/apimatic_core/configuration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/apimatic_core/configuration/test_proxy_settings.py b/tests/apimatic_core/configuration/test_proxy_settings.py
new file mode 100644
index 0000000..b64e56b
--- /dev/null
+++ b/tests/apimatic_core/configuration/test_proxy_settings.py
@@ -0,0 +1,94 @@
+import pytest
+
+from apimatic_core.http.configurations.proxy_settings import ProxySettings
+
+
+class TestProxySettings:
+ def test_has_expected_keys(self):
+ ps = ProxySettings(address="proxy.local")
+ proxies = ps.to_proxies()
+ assert set(proxies.keys()) == {"http", "https"}
+
+ @pytest.mark.parametrize(
+ "address, port, username, password, exp_http, exp_https",
+ [
+ pytest.param(
+ "proxy.local", None, None, None,
+ "http://proxy.local",
+ "https://proxy.local",
+ id="no-auth-no-port",
+ ),
+ pytest.param(
+ "proxy.local", 8080, None, None,
+ "http://proxy.local:8080",
+ "https://proxy.local:8080",
+ id="no-auth-with-port",
+ ),
+ pytest.param(
+ "proxy.local", 8080, "user", "pass",
+ "http://user:pass@proxy.local:8080",
+ "https://user:pass@proxy.local:8080",
+ id="auth-with-port",
+ ),
+ pytest.param(
+ "proxy.local", None, "user", None,
+ # password None -> empty string: "user:@"
+ "http://user:@proxy.local",
+ "https://user:@proxy.local",
+ id="auth-username-only-password-none",
+ ),
+ pytest.param(
+ "proxy.local", None, "a b", "p@ss#",
+ # URL-encoding of space/@/#
+ "http://a%20b:p%40ss%23@proxy.local",
+ "https://a%20b:p%40ss%23@proxy.local",
+ id="auth-with-url-encoding",
+ ),
+ pytest.param(
+ "localhost", None, "", "",
+ # empty username triggers auth block (since not None) -> ':@'
+ "http://:@localhost",
+ "https://:@localhost",
+ id="empty-username-and-password",
+ ),
+ ],
+ )
+ def test_formats(self, address, port, username, password, exp_http, exp_https):
+ ps = ProxySettings(address, port, username, password)
+ proxies = ps.to_proxies()
+ assert proxies["http"] == exp_http
+ assert proxies["https"] == exp_https
+
+ @pytest.mark.parametrize("address", ["proxy.local", "localhost"])
+ def test_no_trailing_colon_when_no_port(self, address):
+ ps = ProxySettings(address)
+ proxies = ps.to_proxies()
+ assert not proxies["http"].endswith(":")
+ assert not proxies["https"].endswith(":")
+ assert "::" not in proxies["http"]
+ assert "::" not in proxies["https"]
+
+ def test_single_colon_before_port(self):
+ ps = ProxySettings(address="proxy.local", port=3128)
+ proxies = ps.to_proxies()
+ assert proxies["http"].endswith(":3128")
+ assert proxies["https"].endswith(":3128")
+ assert "proxy.local::3128" not in proxies["http"]
+ assert "proxy.local::3128" not in proxies["https"]
+
+ # --- NEW: scheme trimming cases (reflecting the reverted, simpler behavior) ---
+
+ def test_trims_http_scheme_no_port(self):
+ ps = ProxySettings(address="http://proxy.local")
+ proxies = ps.to_proxies()
+ assert proxies["http"] == "http://proxy.local"
+ assert proxies["https"] == "https://proxy.local"
+
+ def test_trims_https_scheme_trailing_slash_with_port_and_auth(self):
+ ps = ProxySettings(address="https://proxy.local/", port=8080, username="user", password="secret")
+ proxies = ps.to_proxies()
+ assert proxies["http"] == "http://user:secret@proxy.local:8080"
+ assert proxies["https"] == "https://user:secret@proxy.local:8080"
+ assert not proxies["http"].endswith(":")
+ assert not proxies["https"].endswith(":")
+
diff --git a/tests/apimatic_core/mocks/__init__.py b/tests/apimatic_core/mocks/__init__.py
index 7f6485e..4a51278 100644
--- a/tests/apimatic_core/mocks/__init__.py
+++ b/tests/apimatic_core/mocks/__init__.py
@@ -6,5 +6,7 @@
'exceptions',
'http',
'logger',
- 'union_type_lookup'
+ 'union_type_lookup',
+ 'pagination',
+ 'configuration'
]
\ No newline at end of file
diff --git a/tests/apimatic_core/mocks/configuration/__init__.py b/tests/apimatic_core/mocks/configuration/__init__.py
new file mode 100644
index 0000000..4975aa5
--- /dev/null
+++ b/tests/apimatic_core/mocks/configuration/__init__.py
@@ -0,0 +1,3 @@
+__all__ = [
+ 'global_configuration'
+]
\ No newline at end of file
diff --git a/tests/apimatic_core/mocks/configuration/global_configuration.py b/tests/apimatic_core/mocks/configuration/global_configuration.py
new file mode 100644
index 0000000..d3ae14a
--- /dev/null
+++ b/tests/apimatic_core/mocks/configuration/global_configuration.py
@@ -0,0 +1,30 @@
+from apimatic_core.types.error_case import ErrorCase
+from tests.apimatic_core.mocks.exceptions.global_test_exception import GlobalTestException
+from tests.apimatic_core.mocks.exceptions.nested_model_exception import NestedModelException
+
+
+class GlobalConfiguration:
+ def __init__(self, with_template_message = False):
+ if with_template_message:
+ self._global_errors = {
+ '400': ErrorCase()
+ .error_message_template('error_code => {$statusCode}, header => {$response.header.accept}, '
+ 'body => {$response.body#/ServerCode} - {$response.body#/ServerMessage}')
+ .exception_type(GlobalTestException),
+ '412': ErrorCase()
+ .error_message_template('global error message -> error_code => {$statusCode}, header => '
+ '{$response.header.accept}, body => {$response.body#/ServerCode} - '
+ '{$response.body#/ServerMessage} - {$response.body#/model/name}')
+ .exception_type(NestedModelException)
+ }
+ else:
+ self._global_errors = {
+ '400': ErrorCase().error_message('400 Global').exception_type(GlobalTestException),
+ '412': ErrorCase().error_message('Precondition Failed').exception_type(NestedModelException),
+ '3XX': ErrorCase().error_message('3XX Global').exception_type(GlobalTestException),
+ 'default': ErrorCase().error_message('Invalid response').exception_type(GlobalTestException),
+ }
+
+ def get_global_errors(self):
+ return self._global_errors
+
diff --git a/tests/apimatic_core/mocks/http/http_response_catcher.py b/tests/apimatic_core/mocks/http/http_response_catcher.py
index adbd867..5c0817e 100644
--- a/tests/apimatic_core/mocks/http/http_response_catcher.py
+++ b/tests/apimatic_core/mocks/http/http_response_catcher.py
@@ -10,11 +10,18 @@ class HttpResponseCatcher(HttpCallBack):
after a request is executed.
"""
+ @property
+ def response(self):
+ return self._response
+
+ def __init__(self):
+ self._response = None
+
def on_before_request(self, request):
pass
def on_after_response(self, response):
- self.response = response
+ self._response = response
diff --git a/tests/apimatic_core/mocks/http/mock_paginated_http_client.py b/tests/apimatic_core/mocks/http/mock_paginated_http_client.py
new file mode 100644
index 0000000..867b3d3
--- /dev/null
+++ b/tests/apimatic_core/mocks/http/mock_paginated_http_client.py
@@ -0,0 +1,187 @@
+from apimatic_core.factories.http_response_factory import HttpResponseFactory
+from apimatic_core.http.response.http_response import HttpResponse
+from apimatic_core_interfaces.client.http_client import HttpClient
+
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class MockPaginatedHttpClient(HttpClient):
+ @property
+ def transactions(self):
+ return [
+ {
+ "id": "txn_1",
+ "amount": 100.25,
+ "timestamp": "Tue, 11 Mar 2025 12:00:00 GMT"
+ },
+ {
+ "id": "txn_2",
+ "amount": 200.50,
+ "timestamp": "Tue, 11 Mar 2025 12:05:00 GMT"
+ },
+ {
+ "id": "txn_3",
+ "amount": 150.75,
+ "timestamp": "Tue, 11 Mar 2025 12:10:00 GMT"
+ },
+ {
+ "id": "txn_4",
+ "amount": 50.00,
+ "timestamp": "Tue, 11 Mar 2025 12:15:00 GMT"
+ },
+ {
+ "id": "txn_5",
+ "amount": 500.10,
+ "timestamp": "Tue, 11 Mar 2025 12:20:00 GMT"
+ },
+ {
+ "id": "txn_6",
+ "amount": 75.25,
+ "timestamp": "Tue, 11 Mar 2025 12:25:00 GMT"
+ },
+ {
+ "id": "txn_7",
+ "amount": 300.00,
+ "timestamp": "Tue, 11 Mar 2025 12:30:00 GMT"
+ },
+ {
+ "id": "txn_8",
+ "amount": 400.75,
+ "timestamp": "Tue, 11 Mar 2025 12:35:00 GMT"
+ },
+ {
+ "id": "txn_9",
+ "amount": 120.90,
+ "timestamp": "Tue, 11 Mar 2025 12:40:00 GMT"
+ },
+ {
+ "id": "txn_10",
+ "amount": 250.30,
+ "timestamp": "Tue, 11 Mar 2025 12:45:00 GMT"
+ },
+ {
+ "id": "txn_11",
+ "amount": 99.99,
+ "timestamp": "Tue, 11 Mar 2025 12:50:00 GMT"
+ },
+ {
+ "id": "txn_12",
+ "amount": 350.40,
+ "timestamp": "Tue, 11 Mar 2025 12:55:00 GMT"
+ },
+ {
+ "id": "txn_13",
+ "amount": 80.60,
+ "timestamp": "Tue, 11 Mar 2025 13:00:00 GMT"
+ },
+ {
+ "id": "txn_14",
+ "amount": 60.10,
+ "timestamp": "Tue, 11 Mar 2025 13:05:00 GMT"
+ },
+ {
+ "id": "txn_15",
+ "amount": 199.99,
+ "timestamp": "Tue, 11 Mar 2025 13:10:00 GMT"
+ },
+ {
+ "id": "txn_16",
+ "amount": 500.75,
+ "timestamp": "Tue, 11 Mar 2025 13:15:00 GMT"
+ },
+ {
+ "id": "txn_17",
+ "amount": 650.50,
+ "timestamp": "Tue, 11 Mar 2025 13:20:00 GMT"
+ },
+ {
+ "id": "txn_18",
+ "amount": 180.90,
+ "timestamp": "Tue, 11 Mar 2025 13:25:00 GMT"
+ },
+ {
+ "id": "txn_19",
+ "amount": 90.25,
+ "timestamp": "Tue, 11 Mar 2025 13:30:00 GMT"
+ },
+ {
+ "id": "txn_20",
+ "amount": 320.40,
+ "timestamp": "Tue, 11 Mar 2025 13:35:00 GMT"
+ }
+ ]
+
+ def __init__(self):
+ self._current_index = 0
+ self._page_number = 1
+ self._batch_limit = 5
+ self._should_retry = None
+ self._contains_binary_response = None
+ self.response_factory = HttpResponseFactory()
+
+ def execute(self, request, endpoint_configuration):
+ """Execute a given CoreHttpRequest to get a string response back
+
+ Args:
+ request (HttpRequest): The given HttpRequest to execute.
+ endpoint_configuration (EndpointConfiguration): The endpoint configurations to use.
+
+ Returns:
+ HttpResponse: The response of the CoreHttpRequest.
+
+ """
+ transaction_batch = self.transactions[self._current_index: self._current_index + 5]
+ self._current_index += self._batch_limit
+ self._page_number += 1
+
+ if '/transactions/cursor' in request.query_url:
+ return self.response_factory.create(
+ status_code=200, reason=None, headers=request.headers,
+ body=ApiHelper.json_serialize({
+ "data": transaction_batch,
+ "nextCursor": transaction_batch[-1]['id'] if self._current_index < len(self.transactions) else None
+ }),
+ request=request)
+
+ if '/transactions/offset' in request.query_url:
+ return self.response_factory.create(
+ status_code=200, reason=None, headers=request.headers,
+ body=ApiHelper.json_serialize({
+ "data": transaction_batch
+ }),
+ request=request)
+
+ if '/transactions/links' in request.query_url:
+ return self.response_factory.create(
+ status_code=200, reason=None, headers=request.headers,
+ body=ApiHelper.json_serialize({
+ "data": transaction_batch,
+ "links": {
+ "next": f"/transactions/links?page=${self._page_number + 1}&size={self._batch_limit}",
+ }
+ }),
+ request=request)
+
+ if '/transactions/page' in request.query_url:
+ return self.response_factory.create(
+ status_code=200, reason=None, headers=request.headers,
+ body=ApiHelper.json_serialize({
+ "data": transaction_batch,
+ }),
+ request=request)
+
+
+ def convert_response(self, response, contains_binary_response, http_request):
+ """Converts the Response object of the CoreHttpClient into an
+ CoreHttpResponse object.
+
+ Args:
+ response (dynamic): The original response object.
+ contains_binary_response (bool): The flag to check if the response is of binary type.
+ http_request (HttpRequest): The original HttpRequest object.
+
+ Returns:
+ CoreHttpResponse: The converted CoreHttpResponse object.
+
+ """
+ pass
diff --git a/tests/apimatic_core/mocks/models/api_response.py b/tests/apimatic_core/mocks/models/api_response.py
index 9a4af81..92d7cab 100644
--- a/tests/apimatic_core/mocks/models/api_response.py
+++ b/tests/apimatic_core/mocks/models/api_response.py
@@ -31,8 +31,6 @@ def __init__(self, http_response,
"""
super().__init__(http_response, body, errors)
- if type(self.body) is dict:
- self.cursor = self.body.get('cursor')
def __repr__(self):
return '' % self.text
diff --git a/tests/apimatic_core/mocks/models/links.py b/tests/apimatic_core/mocks/models/links.py
new file mode 100644
index 0000000..b1e8adc
--- /dev/null
+++ b/tests/apimatic_core/mocks/models/links.py
@@ -0,0 +1,89 @@
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class Links(object):
+
+ """Implementation of the 'Links' model.
+
+ Attributes:
+ first (str): The model property of type str.
+ last (str): The model property of type str.
+ prev (str): The model property of type str.
+ next (str): The model property of type str.
+
+ """
+
+ # Create a mapping from Model property names to API property names
+ _names = {
+ "first": 'first',
+ "last": 'last',
+ "prev": 'prev',
+ "_next": 'next'
+ }
+
+ _optionals = [
+ 'first',
+ 'last',
+ 'prev',
+ '_next',
+ ]
+
+ def __init__(self,
+ first=ApiHelper.SKIP,
+ last=ApiHelper.SKIP,
+ prev=ApiHelper.SKIP,
+ _next=ApiHelper.SKIP):
+ """Constructor for the Links class"""
+
+ # Initialize members of the class
+ if first is not ApiHelper.SKIP:
+ self.first = first
+ if last is not ApiHelper.SKIP:
+ self.last = last
+ if prev is not ApiHelper.SKIP:
+ self.prev = prev
+ if _next is not ApiHelper.SKIP:
+ self._next = _next
+
+ @classmethod
+ def from_dictionary(cls,
+ dictionary):
+ """Creates an instance of this model from a dictionary
+
+ Args:
+ dictionary (dictionary): A dictionary representation of the object
+ as obtained from the deserialization of the server's response. The
+ keys MUST match property names in the API description.
+
+ Returns:
+ object: An instance of this structure class.
+
+ """
+
+ if not isinstance(dictionary, dict) or dictionary is None:
+ return None
+
+ # Extract variables from the dictionary
+ first = dictionary.get("first") if dictionary.get("first") else ApiHelper.SKIP
+ last = dictionary.get("last") if dictionary.get("last") else ApiHelper.SKIP
+ prev = dictionary.get("prev") if dictionary.get("prev") else ApiHelper.SKIP
+ _next = dictionary.get("next") if dictionary.get("next") else ApiHelper.SKIP
+ # Return an object of this model
+ return cls(first,
+ last,
+ prev,
+ _next)
+
+ def __repr__(self):
+ return (f'{self.__class__.__name__}('
+ f'first={(self.first if hasattr(self, "first") else None)!r}, '
+ f'last={(self.last if hasattr(self, "last") else None)!r}, '
+ f'prev={(self.prev if hasattr(self, "prev") else None)!r}, '
+ f'next={(self._next if hasattr(self, "_next") else None)!r})')
+
+ def __str__(self):
+ return (f'{self.__class__.__name__}('
+ f'first={(self.first if hasattr(self, "first") else None)!s}, '
+ f'last={(self.last if hasattr(self, "last") else None)!s}, '
+ f'prev={(self.prev if hasattr(self, "prev") else None)!s}, '
+ f'next={(self._next if hasattr(self, "_next") else None)!s})')
diff --git a/tests/apimatic_core/mocks/models/transaction.py b/tests/apimatic_core/mocks/models/transaction.py
new file mode 100644
index 0000000..2f59bba
--- /dev/null
+++ b/tests/apimatic_core/mocks/models/transaction.py
@@ -0,0 +1,79 @@
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class Transaction(object):
+
+ """Implementation of the 'Transaction' model.
+
+ Attributes:
+ id (str): The model property of type str.
+ amount (float): The model property of type float.
+ timestamp (datetime): The model property of type datetime.
+
+ """
+
+ # Create a mapping from Model property names to API property names
+ _names = {
+ "_id": 'id',
+ "amount": 'amount',
+ "timestamp": 'timestamp'
+ }
+
+ _optionals = [
+ '_id',
+ 'amount',
+ 'timestamp',
+ ]
+
+ def __init__(self,
+ _id=ApiHelper.SKIP,
+ amount=ApiHelper.SKIP,
+ timestamp=ApiHelper.SKIP):
+ """Constructor for the Transaction class"""
+
+ # Initialize members of the class
+ if _id is not ApiHelper.SKIP:
+ self._id = _id
+ if amount is not ApiHelper.SKIP:
+ self.amount = amount
+ if timestamp is not ApiHelper.SKIP:
+ self.timestamp = ApiHelper.apply_datetime_converter(timestamp, ApiHelper.HttpDateTime) if timestamp else None
+
+ @classmethod
+ def from_dictionary(cls,
+ dictionary):
+ """Creates an instance of this model from a dictionary
+
+ Args:
+ dictionary (dictionary): A dictionary representation of the object
+ as obtained from the deserialization of the server's response. The
+ keys MUST match property names in the API description.
+
+ Returns:
+ object: An instance of this structure class.
+
+ """
+
+ if not isinstance(dictionary, dict) or dictionary is None:
+ return None
+
+ # Extract variables from the dictionary
+ _id = dictionary.get("id") if dictionary.get("id") else ApiHelper.SKIP
+ amount = dictionary.get("amount") if dictionary.get("amount") else ApiHelper.SKIP
+ timestamp = ApiHelper.HttpDateTime.from_value(dictionary.get("timestamp")).datetime if dictionary.get("timestamp") else ApiHelper.SKIP
+ # Return an object of this model
+ return cls(_id,
+ amount,
+ timestamp)
+
+ def __repr__(self):
+ return (f'{self.__class__.__name__}('
+ f'id={(self._id if hasattr(self, "_id") else None)!r}, '
+ f'amount={(self.amount if hasattr(self, "amount") else None)!r}, '
+ f'timestamp={(self.timestamp if hasattr(self, "timestamp") else None)!r})')
+
+ def __str__(self):
+ return (f'{self.__class__.__name__}('
+ f'id={(self._id if hasattr(self, "_id") else None)!s}, '
+ f'amount={(self.amount if hasattr(self, "amount") else None)!s}, '
+ f'timestamp={(self.timestamp if hasattr(self, "timestamp") else None)!s})')
diff --git a/tests/apimatic_core/mocks/models/transactions_cursored.py b/tests/apimatic_core/mocks/models/transactions_cursored.py
new file mode 100644
index 0000000..8b51282
--- /dev/null
+++ b/tests/apimatic_core/mocks/models/transactions_cursored.py
@@ -0,0 +1,78 @@
+from apimatic_core.utilities.api_helper import ApiHelper
+from tests.apimatic_core.mocks.models.transaction import Transaction
+
+
+class TransactionsCursored(object):
+
+ """Implementation of the 'TransactionsCursored' model.
+
+ Attributes:
+ data (List[Transaction]): The model property of type List[Transaction].
+ next_cursor (str): Cursor for the next page of results.
+
+ """
+
+ # Create a mapping from Model property names to API property names
+ _names = {
+ "data": 'data',
+ "next_cursor": 'nextCursor'
+ }
+
+ _optionals = [
+ 'data',
+ 'next_cursor',
+ ]
+
+ _nullables = [
+ 'next_cursor',
+ ]
+
+ def __init__(self,
+ data=ApiHelper.SKIP,
+ next_cursor=ApiHelper.SKIP):
+ """Constructor for the TransactionsCursored class"""
+
+ # Initialize members of the class
+ if data is not ApiHelper.SKIP:
+ self.data = data
+ if next_cursor is not ApiHelper.SKIP:
+ self.next_cursor = next_cursor
+
+ @classmethod
+ def from_dictionary(cls,
+ dictionary):
+ """Creates an instance of this model from a dictionary
+
+ Args:
+ dictionary (dictionary): A dictionary representation of the object
+ as obtained from the deserialization of the server's response. The
+ keys MUST match property names in the API description.
+
+ Returns:
+ object: An instance of this structure class.
+
+ """
+
+ if not isinstance(dictionary, dict) or dictionary is None:
+ return None
+
+ # Extract variables from the dictionary
+ data = None
+ if dictionary.get('data') is not None:
+ data = [Transaction.from_dictionary(x) for x in dictionary.get('data')]
+ else:
+ data = ApiHelper.SKIP
+ next_cursor = dictionary.get("nextCursor") if "nextCursor" in dictionary.keys() else ApiHelper.SKIP
+ # Return an object of this model
+ return cls(data,
+ next_cursor)
+
+ def __repr__(self):
+ return (f'{self.__class__.__name__}('
+ f'data={(self.data if hasattr(self, "data") else None)!r}, '
+ f'next_cursor={(self.next_cursor if hasattr(self, "next_cursor") else None)!r})')
+
+ def __str__(self):
+ return (f'{self.__class__.__name__}('
+ f'data={(self.data if hasattr(self, "data") else None)!s}, '
+ f'next_cursor={(self.next_cursor if hasattr(self, "next_cursor") else None)!s})')
diff --git a/tests/apimatic_core/mocks/models/transactions_linked.py b/tests/apimatic_core/mocks/models/transactions_linked.py
new file mode 100644
index 0000000..ea3cab4
--- /dev/null
+++ b/tests/apimatic_core/mocks/models/transactions_linked.py
@@ -0,0 +1,75 @@
+from apimatic_core.utilities.api_helper import ApiHelper
+from tests.apimatic_core.mocks.models.links import Links
+from tests.apimatic_core.mocks.models.transaction import Transaction
+
+
+class TransactionsLinked(object):
+
+ """Implementation of the 'TransactionsLinked' model.
+
+ Attributes:
+ data (List[Transaction]): The model property of type List[Transaction].
+ links (Links): The model property of type Links.
+
+ """
+
+ # Create a mapping from Model property names to API property names
+ _names = {
+ "data": 'data',
+ "links": 'links'
+ }
+
+ _optionals = [
+ 'data',
+ 'links',
+ ]
+
+ def __init__(self,
+ data=ApiHelper.SKIP,
+ links=ApiHelper.SKIP):
+ """Constructor for the TransactionsLinked class"""
+
+ # Initialize members of the class
+ if data is not ApiHelper.SKIP:
+ self.data = data
+ if links is not ApiHelper.SKIP:
+ self.links = links
+
+ @classmethod
+ def from_dictionary(cls,
+ dictionary):
+ """Creates an instance of this model from a dictionary
+
+ Args:
+ dictionary (dictionary): A dictionary representation of the object
+ as obtained from the deserialization of the server's response. The
+ keys MUST match property names in the API description.
+
+ Returns:
+ object: An instance of this structure class.
+
+ """
+
+ if not isinstance(dictionary, dict) or dictionary is None:
+ return None
+
+ # Extract variables from the dictionary
+ data = None
+ if dictionary.get('data') is not None:
+ data = [Transaction.from_dictionary(x) for x in dictionary.get('data')]
+ else:
+ data = ApiHelper.SKIP
+ links = Links.from_dictionary(dictionary.get('links')) if 'links' in dictionary.keys() else ApiHelper.SKIP
+ # Return an object of this model
+ return cls(data,
+ links)
+
+ def __repr__(self):
+ return (f'{self.__class__.__name__}('
+ f'data={(self.data if hasattr(self, "data") else None)!r}, '
+ f'links={(self.links if hasattr(self, "links") else None)!r})')
+
+ def __str__(self):
+ return (f'{self.__class__.__name__}('
+ f'data={(self.data if hasattr(self, "data") else None)!s}, '
+ f'links={(self.links if hasattr(self, "links") else None)!s})')
diff --git a/tests/apimatic_core/mocks/models/transactions_offset.py b/tests/apimatic_core/mocks/models/transactions_offset.py
new file mode 100644
index 0000000..baddf97
--- /dev/null
+++ b/tests/apimatic_core/mocks/models/transactions_offset.py
@@ -0,0 +1,64 @@
+from apimatic_core.utilities.api_helper import ApiHelper
+from tests.apimatic_core.mocks.models.transaction import Transaction
+
+
+class TransactionsOffset(object):
+
+ """Implementation of the 'TransactionsOffset' model.
+
+ Attributes:
+ data (List[Transaction]): The model property of type List[Transaction].
+
+ """
+
+ # Create a mapping from Model property names to API property names
+ _names = {
+ "data": 'data'
+ }
+
+ _optionals = [
+ 'data',
+ ]
+
+ def __init__(self,
+ data=ApiHelper.SKIP):
+ """Constructor for the TransactionsOffset class"""
+
+ # Initialize members of the class
+ if data is not ApiHelper.SKIP:
+ self.data = data
+
+ @classmethod
+ def from_dictionary(cls,
+ dictionary):
+ """Creates an instance of this model from a dictionary
+
+ Args:
+ dictionary (dictionary): A dictionary representation of the object
+ as obtained from the deserialization of the server's response. The
+ keys MUST match property names in the API description.
+
+ Returns:
+ object: An instance of this structure class.
+
+ """
+
+ if not isinstance(dictionary, dict) or dictionary is None:
+ return None
+
+ # Extract variables from the dictionary
+ data = None
+ if dictionary.get('data') is not None:
+ data = [Transaction.from_dictionary(x) for x in dictionary.get('data')]
+ else:
+ data = ApiHelper.SKIP
+ # Return an object of this model
+ return cls(data)
+
+ def __repr__(self):
+ return (f'{self.__class__.__name__}('
+ f'data={(self.data if hasattr(self, "data") else None)!r})')
+
+ def __str__(self):
+ return (f'{self.__class__.__name__}('
+ f'data={(self.data if hasattr(self, "data") else None)!s})')
diff --git a/tests/apimatic_core/mocks/pagination/__init__.py b/tests/apimatic_core/mocks/pagination/__init__.py
new file mode 100644
index 0000000..7c952be
--- /dev/null
+++ b/tests/apimatic_core/mocks/pagination/__init__.py
@@ -0,0 +1,4 @@
+__all__ = [
+ 'paged_iterable',
+ 'paged_api_response.py'
+]
\ No newline at end of file
diff --git a/tests/apimatic_core/mocks/pagination/paged_api_response.py b/tests/apimatic_core/mocks/pagination/paged_api_response.py
new file mode 100644
index 0000000..5432cec
--- /dev/null
+++ b/tests/apimatic_core/mocks/pagination/paged_api_response.py
@@ -0,0 +1,224 @@
+from apimatic_core.http.response.http_response import HttpResponse
+from tests.apimatic_core.mocks.models.api_response import ApiResponse
+
+
+class PagedApiResponse(ApiResponse):
+ """
+ The base class for paged response types.
+ """
+
+ def __init__(self, http_response, errors, body, paginated_field_getter):
+ """
+ Initialize the instance.
+
+ Args:
+ http_response: The original HTTP response object.
+ errors: Any errors returned by the server.
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ """
+ super().__init__(http_response, body, errors)
+ self._paginated_field_getter = paginated_field_getter
+
+ def items(self):
+ """
+ Returns an iterator over the items in the paginated response body.
+ """
+ return iter(self._paginated_field_getter(self.body))
+
+class LinkPagedApiResponse(PagedApiResponse):
+ """
+ Represents a paginated API response for link based pagination.
+ """
+
+ @property
+ def next_link(self):
+ """
+ Returns the next link url using which the current paginated response is fetched.
+ """
+ return self._next_link
+
+ def __init__(self, http_response, errors, body, paginated_field_getter, next_link):
+ """
+ Initialize the instance.
+
+ Args:
+ http_response: The original HTTP response object.
+ errors: Any errors returned by the server.
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ next_link: The next link url using which the current paginated response is fetched.
+ """
+ super().__init__(http_response, body, errors, paginated_field_getter)
+ self._next_link = next_link
+
+ def __str__(self):
+ """
+ Return a string representation of the LinkPagedResponse, including the next_link and body attributes.
+ """
+ return f"LinkPagedResponse(status_code={self.status_code}, body={self.body}, next_link={self.next_link})"
+
+ @classmethod
+ def create(cls, base_api_response, paginated_field_getter=None, next_link=None):
+ """
+ Create a new instance using the base_api_response and optional pagination parameters.
+
+ Args:
+ base_api_response: The base HTTP response object.
+ paginated_field_getter: Optional callable to extract paginated field.
+ next_link: The next link url using which the current paginated response is fetched.
+
+ Returns:
+ An instance of the class.
+ """
+ return cls(HttpResponse(base_api_response.status_code, base_api_response.reason_phrase,
+ base_api_response.headers, base_api_response.text, base_api_response.request),
+ base_api_response.body, base_api_response.errors, paginated_field_getter, next_link)
+
+class CursorPagedApiResponse(PagedApiResponse):
+ """
+ Represents a paginated API response for cursor based pagination.
+ """
+
+ @property
+ def next_cursor(self):
+ """
+ Returns the next cursor using which the current paginated response is fetched.
+ """
+ return self._next_cursor
+
+ def __init__(self, http_response, errors, body, paginated_field_getter, next_cursor):
+ """
+ Initialize the instance.
+
+ Args:
+ http_response: The original HTTP response object.
+ errors: Any errors returned by the server.
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ next_cursor: The next cursor using which the current paginated response is fetched.
+ """
+ super().__init__(http_response, body, errors, paginated_field_getter)
+ self._next_cursor = next_cursor
+
+ def __str__(self):
+ """
+ Return a string representation of the CursorPagedResponse, including the next_cursor and body attributes.
+ """
+ return f"CursorPagedResponse(status_code={self.status_code}, body={self.body}, next_cursor={self.next_cursor})"
+
+ @classmethod
+ def create(cls, base_api_response, paginated_field_getter=None, next_cursor=None):
+ """
+ Create a new instance using the base_api_response and optional pagination parameters.
+
+ Args:
+ base_api_response: The base HTTP response object.
+ paginated_field_getter: Optional callable to extract paginated field.
+ next_cursor: The next cursor using which the current paginated response is fetched.
+
+ Returns:
+ An instance of the class.
+ """
+ return cls(HttpResponse(base_api_response.status_code, base_api_response.reason_phrase,
+ base_api_response.headers, base_api_response.text, base_api_response.request),
+ base_api_response.body, base_api_response.errors, paginated_field_getter, next_cursor)
+
+class OffsetPagedApiResponse(PagedApiResponse):
+ """
+ Represents a paginated API response for offset based pagination.
+ """
+
+ @property
+ def offset(self):
+ """
+ Returns the offset using which the current paginated response is fetched.
+ """
+ return self._offset
+
+ def __init__(self, http_response, errors, body, paginated_field_getter, offset):
+ """
+ Initialize the instance.
+
+ Args:
+ http_response: The original HTTP response object.
+ errors: Any errors returned by the server.
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ offset: The offset using which the current paginated response is fetched.
+ """
+ super().__init__(http_response, body, errors, paginated_field_getter)
+ self._offset = offset
+
+ def __str__(self):
+ """
+ Return a string representation of the OffsetPagedResponse, including the offset and body attributes.
+ """
+ return f"OffsetPagedResponse(status_code={self.status_code}, body={self.body}, offset={self.offset})"
+
+ @classmethod
+ def create(cls, base_api_response, paginated_field_getter=None, offset=None):
+ """
+ Create a new instance using the base_api_response and optional pagination parameters.
+
+ Args:
+ base_api_response: The base HTTP response object.
+ paginated_field_getter: Optional callable to extract paginated field.
+ offset: The offset using which the current paginated response is fetched.
+
+ Returns:
+ An instance of the class.
+ """
+ return cls(HttpResponse(base_api_response.status_code, base_api_response.reason_phrase,
+ base_api_response.headers, base_api_response.text, base_api_response.request),
+ base_api_response.body, base_api_response.errors, paginated_field_getter, offset)
+
+class NumberPagedApiResponse(PagedApiResponse):
+ """
+ Represents a paginated API response for page number based pagination.
+ """
+
+ @property
+ def page_number(self):
+ """
+ Returns the page number using which the current paginated response is fetched.
+ """
+ return self._page_number
+
+ def __init__(self, http_response, errors, body, paginated_field_getter, page_number):
+ """
+ Initialize the instance.
+
+ Args:
+ http_response: The original HTTP response object.
+ errors: Any errors returned by the server.
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ page_number: The page number using which the current paginated response is fetched.
+ """
+ super().__init__(http_response, body, errors, paginated_field_getter)
+ self._page_number = page_number
+
+ def __str__(self):
+ """
+ Return a string representation of the NumberPagedResponse, including the page_number and body attributes.
+ """
+ return f"NumberPagedResponse(status_code={self.status_code}, body={self.body}, page_number={self.page_number})"
+
+ @classmethod
+ def create(cls, base_api_response, paginated_field_getter=None, page_number=None):
+ """
+ Create a new instance using the base_api_response and optional pagination parameters.
+
+ Args:
+ base_api_response: The base HTTP response object.
+ paginated_field_getter: Optional callable to extract paginated field.
+ page_number: The page number using which the current paginated response is fetched.
+
+ Returns:
+ An instance of the class.
+ """
+ return cls(HttpResponse(base_api_response.status_code, base_api_response.reason_phrase,
+ base_api_response.headers, base_api_response.text, base_api_response.request),
+ base_api_response.body, base_api_response.errors, paginated_field_getter, page_number)
+
diff --git a/tests/apimatic_core/mocks/pagination/paged_iterable.py b/tests/apimatic_core/mocks/pagination/paged_iterable.py
new file mode 100644
index 0000000..5e30a55
--- /dev/null
+++ b/tests/apimatic_core/mocks/pagination/paged_iterable.py
@@ -0,0 +1,33 @@
+
+class PagedIterable:
+ """
+ An iterable wrapper for paginated data that allows iteration
+ over pages or individual items.
+ """
+
+ def __init__(self, paginated_data):
+ """
+ Initialize the instance.
+
+ Args:
+ paginated_data: PaginatedData instance containing pages and items.
+ """
+ self._paginated_data = paginated_data
+
+ def pages(self):
+ """
+ Retrieve an iterable collection of all pages in the paginated data.
+
+ Returns:
+ The Iterable of pages.
+ """
+ return self._paginated_data.pages()
+
+ def __iter__(self):
+ """
+ Provides iterator functionality to sequentially access all items across all pages.
+
+ Returns:
+ The Iterator of all items in the paginated data.
+ """
+ return iter(self._paginated_data)
diff --git a/tests/apimatic_core/mocks/pagination/paged_response.py b/tests/apimatic_core/mocks/pagination/paged_response.py
new file mode 100644
index 0000000..c73fdc1
--- /dev/null
+++ b/tests/apimatic_core/mocks/pagination/paged_response.py
@@ -0,0 +1,150 @@
+
+class PagedResponse:
+ """
+ The base class for paged response types.
+ """
+
+ @property
+ def body(self):
+ """
+ Returns the content associated with the Page instance.
+ """
+ return self._body
+
+ def __init__(self, body, paginated_field_getter):
+ """
+ Initialize the instance.
+
+ Args:
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ """
+ self._body = body
+ self._paginated_field_getter = paginated_field_getter
+
+ def items(self):
+ """
+ Returns an iterator over the items in the paginated response body.
+ """
+ return iter(self._paginated_field_getter(self.body))
+
+class LinkPagedResponse(PagedResponse):
+ """
+ Represents a paginated API response for link based pagination.
+ """
+
+ @property
+ def next_link(self):
+ """
+ Returns the next link url using which the current paginated response is fetched.
+ """
+ return self._next_link
+
+ def __init__(self, body, paginated_field_getter, next_link):
+ """
+ Initialize the instance.
+
+ Args:
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ next_link: The next link url using which the current paginated response is fetched.
+ """
+ super().__init__(body, paginated_field_getter)
+ self._next_link = next_link
+
+ def __str__(self):
+ """
+ Return a string representation of the LinkPagedResponse, including the next_link and body attributes.
+ """
+ return f"LinkPagedResponse(body={self.body}, next_link={self.next_link})"
+
+class CursorPagedResponse(PagedResponse):
+ """
+ Represents a paginated API response for cursor based pagination.
+ """
+
+ @property
+ def next_cursor(self):
+ """
+ Returns the next cursor using which the current paginated response is fetched.
+ """
+ return self._next_cursor
+
+ def __init__(self, body, paginated_field_getter, next_cursor):
+ """
+ Initialize the instance.
+
+ Args:
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ next_cursor: The next cursor using which the current paginated response is fetched.
+ """
+ super().__init__(body, paginated_field_getter)
+ self._next_cursor = next_cursor
+
+ def __str__(self):
+ """
+ Return a string representation of the CursorPagedResponse, including the next_cursor and body attributes.
+ """
+ return f"CursorPagedResponse(body={self.body}, next_cursor={self.next_cursor})"
+
+class OffsetPagedResponse(PagedResponse):
+ """
+ Represents a paginated API response for offset based pagination.
+ """
+
+ @property
+ def offset(self):
+ """
+ Returns the offset using which the current paginated response is fetched.
+ """
+ return self._offset
+
+ def __init__(self, body, paginated_field_getter, offset):
+ """
+ Initialize the instance.
+
+ Args:
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ offset: The offset using which the current paginated response is fetched.
+ """
+ super().__init__(body, paginated_field_getter)
+ self._offset = offset
+
+ def __str__(self):
+ """
+ Return a string representation of the OffsetPagedResponse, including the offset and body attributes.
+ """
+ return f"OffsetPagedResponse(body={self.body}, offset={self.offset})"
+
+class NumberPagedResponse(PagedResponse):
+ """
+ Represents a paginated API response for page number based pagination.
+ """
+
+ @property
+ def page_number(self):
+ """
+ Returns the page number using which the current paginated response is fetched.
+ """
+ return self._page_number
+
+ def __init__(self, body, paginated_field_getter, page_number):
+ """
+ Initialize the instance.
+
+ Args:
+ body: The paginated response model.
+ paginated_field_getter: The value getter for the paginated payload, to provide the iterator on this field.
+ page_number: The page number using which the current paginated response is fetched.
+ """
+ super().__init__(body, paginated_field_getter)
+ self._page_number = page_number
+
+ def __str__(self):
+ """
+ Return a string representation of the NumberPagedResponse, including the page_number and body attributes.
+ """
+ return f"NumberPagedResponse(body={self.body}, page_number={self.page_number})"
+
diff --git a/tests/apimatic_core/pagination_tests/__init__.py b/tests/apimatic_core/pagination_tests/__init__.py
new file mode 100644
index 0000000..e90a249
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/__init__.py
@@ -0,0 +1,5 @@
+__all__ = [
+ 'strategies',
+ 'test_paginated_data',
+ 'test_pagination_strategy'
+]
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/strategies/__init__.py b/tests/apimatic_core/pagination_tests/strategies/__init__.py
new file mode 100644
index 0000000..4c513b5
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/strategies/__init__.py
@@ -0,0 +1,7 @@
+__all__ = [
+ 'test_offset_pagination',
+ 'test_page_pagination',
+ 'test_cursor_pagination',
+ 'test_link_pagination',
+ 'strategy_base'
+]
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/strategies/strategy_base.py b/tests/apimatic_core/pagination_tests/strategies/strategy_base.py
new file mode 100644
index 0000000..6af2af8
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/strategies/strategy_base.py
@@ -0,0 +1,66 @@
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class StrategyBase:
+
+ @staticmethod
+ def assert_initial_param_extraction(
+ mocker,
+ mock_request_builder,
+ mock_metadata_wrapper,
+ input_pointer,
+ initial_params,
+ expected_value,
+ json_pointer_return_value,
+ default_value,
+ pagination_instance_creator
+ ):
+ # Set request builder params
+ if PaginationStrategy.PATH_PARAMS_IDENTIFIER in input_pointer:
+ mock_request_builder._template_params = initial_params
+ elif PaginationStrategy.QUERY_PARAMS_IDENTIFIER in input_pointer:
+ mock_request_builder._query_params = initial_params
+ elif PaginationStrategy.HEADER_PARAMS_IDENTIFIER in input_pointer:
+ mock_request_builder._header_params = initial_params
+ elif PaginationStrategy.BODY_PARAM_IDENTIFIER in input_pointer:
+ mock_request_builder._body_param = initial_params
+
+ # Mock helper methods
+ mock_split = mocker.patch.object(ApiHelper, 'split_into_parts',
+ return_value=(input_pointer.split('#')[0], input_pointer.split('#')[1]))
+ mock_json_pointer = mocker.patch.object(ApiHelper, 'get_value_by_json_pointer',
+ return_value=json_pointer_return_value)
+
+ # Run
+ pagination_instance = pagination_instance_creator(input_pointer, mock_metadata_wrapper)
+ result = pagination_instance._get_initial_request_param_value(
+ mock_request_builder, input_pointer, default_value
+ ) if default_value is not None else pagination_instance._get_initial_request_param_value(
+ mock_request_builder, input_pointer)
+
+ # Assert
+ mock_split.assert_called_once_with(input_pointer)
+
+ if input_pointer.startswith((
+ PaginationStrategy.PATH_PARAMS_IDENTIFIER,
+ PaginationStrategy.QUERY_PARAMS_IDENTIFIER,
+ PaginationStrategy.HEADER_PARAMS_IDENTIFIER,
+ PaginationStrategy.BODY_PARAM_IDENTIFIER
+ )):
+ if PaginationStrategy.PATH_PARAMS_IDENTIFIER in input_pointer:
+ accessed = mock_request_builder.template_params
+ mock_json_pointer.assert_called_once_with(accessed, f"{input_pointer.split('#')[1]}/value")
+ elif PaginationStrategy.QUERY_PARAMS_IDENTIFIER in input_pointer:
+ accessed = mock_request_builder.query_params
+ mock_json_pointer.assert_called_once_with(accessed, input_pointer.split('#')[1])
+ elif PaginationStrategy.HEADER_PARAMS_IDENTIFIER in input_pointer:
+ accessed = mock_request_builder.header_params
+ mock_json_pointer.assert_called_once_with(accessed, input_pointer.split('#')[1])
+ elif PaginationStrategy.BODY_PARAM_IDENTIFIER in input_pointer:
+ accessed = mock_request_builder.body_params
+ mock_json_pointer.assert_called_once_with(accessed, input_pointer.split('#')[1])
+ else:
+ mock_json_pointer.assert_not_called()
+
+ assert result == expected_value
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/strategies/test_cursor_pagination.py b/tests/apimatic_core/pagination_tests/strategies/test_cursor_pagination.py
new file mode 100644
index 0000000..31ee4ca
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/strategies/test_cursor_pagination.py
@@ -0,0 +1,254 @@
+import pytest
+
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.utilities.api_helper import ApiHelper
+from apimatic_core.request_builder import RequestBuilder
+from apimatic_core.pagination.strategies.cursor_pagination import CursorPagination
+
+
+class TestCursorPagination:
+
+ @pytest.fixture
+ def mock_metadata_wrapper(self, mocker):
+ return mocker.Mock(name="metadata_wrapper_mock")
+
+ @pytest.fixture
+ def mock_request_builder(self, mocker):
+ rb = mocker.Mock(spec=RequestBuilder)
+ rb.template_params = {
+ "cursor": { "value": "initial_path_cursor", "encode": True}
+ }
+ rb.query_params = {"cursor": "initial_query_cursor"}
+ rb.header_params = {"cursor": "initial_header_cursor"}
+ return rb
+
+ @pytest.fixture
+ def mock_request_builder_with_body_param(self, mocker):
+ rb = mocker.Mock(spec=RequestBuilder)
+ rb.template_params = {
+ "cursor": {"value": "initial_path_cursor", "encode": True}
+ }
+ rb.query_params = {"cursor": "initial_query_cursor"}
+ rb.header_params = {"cursor": "initial_header_cursor"}
+ rb.body_params = {"cursor": "initial_body_cursor"}
+ rb.form_params = {"cursor": "initial_form_cursor"}
+ return rb
+
+ @pytest.fixture
+ def mock_request_builder_with_form_params(self, mocker):
+ rb = mocker.Mock(spec=RequestBuilder)
+ rb.template_params = {
+ "cursor": {"value": "initial_path_cursor", "encode": True}
+ }
+ rb.query_params = {"cursor": "initial_query_cursor"}
+ rb.header_params = {"cursor": "initial_header_cursor"}
+ rb.form_params = {"cursor": "initial_form_cursor"}
+ return rb
+
+ @pytest.fixture
+ def mock_last_response(self, mocker):
+ response = mocker.Mock()
+ response.text = '{"data": [{"id": 1}], "next_cursor": "next_page_cursor"}'
+ response.headers = {'Content-Type': 'application/json'}
+ return response
+
+ @pytest.fixture
+ def mock_paginated_data(self, mocker, mock_request_builder, mock_last_response):
+ paginated_data = mocker.Mock()
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.last_response = mock_last_response
+ return paginated_data
+
+ # Test __init__
+ def test_init_success(self, mock_metadata_wrapper):
+ cp = CursorPagination(output="$response.body#/next_cursor", input_="$request.query#/cursor", metadata_wrapper=mock_metadata_wrapper)
+ assert cp._output == "$response.body#/next_cursor"
+ assert cp._input == "$request.query#/cursor"
+ assert cp._metadata_wrapper == mock_metadata_wrapper
+ assert cp._cursor_value is None
+
+ def test_init_input_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Input pointer for cursor based pagination cannot be None"):
+ CursorPagination(output="$response.body#/next_cursor", input_=None, metadata_wrapper=mock_metadata_wrapper)
+
+ def test_init_metadata_wrapper_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Metadata wrapper for the pagination cannot be None"):
+ CursorPagination(output=None, input_=None, metadata_wrapper=None)
+
+ def test_init_output_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Output pointer for cursor based pagination cannot be None"):
+ CursorPagination(output=None, input_="$request.query#/cursor", metadata_wrapper=mock_metadata_wrapper)
+
+ # Test apply
+ def test_apply_initial_call(self, mocker, mock_request_builder, mock_metadata_wrapper):
+ paginated_data = mocker.Mock()
+ paginated_data.last_response = None
+ paginated_data.request_builder = mock_request_builder
+
+ # Mock _get_initial_cursor_value
+ mock_get_initial_cursor_value = mocker.patch.object(
+ CursorPagination,
+ '_get_initial_cursor_value',
+ return_value='initial_cursor'
+ )
+
+ cp = CursorPagination(
+ input_="$request.query#/cursor",
+ output="$response.body#/next_cursor",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+
+ result = cp.apply(paginated_data)
+
+ mock_get_initial_cursor_value.assert_called_once_with(mock_request_builder, "$request.query#/cursor")
+ assert cp._cursor_value == "initial_cursor"
+ assert result == mock_request_builder
+
+ def test_apply_subsequent_call_with_cursor(self, mocker, mock_paginated_data,
+ mock_request_builder, mock_last_response, mock_metadata_wrapper):
+ # Patch ApiHelper.resolve_response_pointer
+ mock_resolve_response_pointer = mocker.patch.object(ApiHelper, 'resolve_response_pointer',
+ return_value="next_page_cursor_from_response")
+
+ mock_get_updated_request_builder = mocker.patch.object(CursorPagination, 'get_updated_request_builder',
+ return_value=mock_request_builder)
+
+ cp = CursorPagination(
+ input_="$request.query#/cursor",
+ output="$response.body#/next_cursor",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+
+ result = cp.apply(mock_paginated_data)
+
+ mock_resolve_response_pointer.assert_called_once_with(
+ "$response.body#/next_cursor",
+ mock_last_response.text,
+ mock_last_response.headers
+ )
+
+ mock_get_updated_request_builder.assert_called_once_with(
+ mock_request_builder, "$request.query#/cursor", "next_page_cursor_from_response"
+ )
+
+ assert cp._cursor_value == "next_page_cursor_from_response"
+ assert result == mock_request_builder
+
+ def test_apply_subsequent_call_no_cursor_found(self, mocker, mock_paginated_data, mock_metadata_wrapper):
+ # Patch ApiHelper.resolve_response_pointer to return None
+ mock_resolve_response_pointer = mocker.patch.object(ApiHelper, 'resolve_response_pointer', return_value=None)
+ # Ensure get_updated_request_builder is NOT called
+ spy_get_updated_request_builder = mocker.spy(PaginationStrategy, 'get_updated_request_builder')
+
+ cp = CursorPagination(
+ input_="$request.query#/cursor",
+ output="$response.body#/next_cursor",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+
+ result = cp.apply(mock_paginated_data)
+
+ mock_resolve_response_pointer.assert_called_once_with(
+ "$response.body#/next_cursor",
+ mock_paginated_data.last_response.text,
+ mock_paginated_data.last_response.headers
+ )
+ assert cp._cursor_value is None
+ spy_get_updated_request_builder.assert_not_called()
+ assert result is None
+
+ # Test apply_metadata_wrapper
+ def test_apply_metadata_wrapper(self, mock_metadata_wrapper, mocker):
+ mock_metadata_wrapper.return_value = "wrapped_response"
+
+ cp = CursorPagination(
+ input_="$request.query#/cursor",
+ output="$response.body#/next_cursor",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+ cp._cursor_value = "some_cursor_value" # Set a cursor value for the test
+ mock_paged_response = mocker.Mock()
+
+ result = cp.apply_metadata_wrapper(mock_paged_response)
+
+ mock_metadata_wrapper.assert_called_once_with(mock_paged_response, "some_cursor_value")
+ assert result == "wrapped_response"
+
+
+ def test_is_applicable_for_none_response(self, mock_metadata_wrapper):
+ cp = CursorPagination(
+ input_="$request.query#/cursor",
+ output="$response.body#/next_cursor",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+ result = cp.is_applicable(None)
+ assert result == True
+
+ # Test _get_initial_cursor_value
+ def test_get_initial_cursor_value_path(self, mocker, mock_request_builder):
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.PATH_PARAMS_IDENTIFIER, "/path_cursor"))
+ mock_get_value_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'get_value_by_json_pointer', return_value="initial_path_cursor")
+
+ result = CursorPagination._get_initial_cursor_value(
+ mock_request_builder, "$request.path#/cursor")
+ mock_split_into_parts.assert_called_once_with("$request.path#/cursor")
+ mock_get_value_by_json_pointer.assert_called_once_with(
+ mock_request_builder.template_params, "/path_cursor/value")
+ assert result == "initial_path_cursor"
+
+ def test_get_initial_cursor_value_query(self, mocker, mock_request_builder):
+ mock_split_into_parts = mocker.patch.object(ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.QUERY_PARAMS_IDENTIFIER, "/cursor"))
+ mock_get_value_by_json_pointer = mocker.patch.object(ApiHelper, 'get_value_by_json_pointer', return_value="initial_query_cursor")
+
+ result = CursorPagination._get_initial_cursor_value(mock_request_builder, "$request.query#/cursor")
+ mock_split_into_parts.assert_called_once_with("$request.query#/cursor")
+ mock_get_value_by_json_pointer.assert_called_once_with(mock_request_builder.query_params, "/cursor")
+ assert result == "initial_query_cursor"
+
+ def test_get_initial_cursor_value_json_body(self, mocker, mock_request_builder_with_body_param):
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.BODY_PARAM_IDENTIFIER, "/cursor"))
+ mock_get_value_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'get_value_by_json_pointer', return_value="initial_body_cursor")
+
+ result = CursorPagination._get_initial_cursor_value(
+ mock_request_builder_with_body_param, "$request.body#/cursor")
+ mock_split_into_parts.assert_called_once_with("$request.body#/cursor")
+ mock_get_value_by_json_pointer.assert_called_once_with(
+ mock_request_builder_with_body_param.body_params, "/cursor")
+ assert result == "initial_body_cursor"
+
+ def test_get_initial_cursor_value_form_body(self, mocker, mock_request_builder_with_form_params):
+ mock_request_builder_with_form_params.body_params = None
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.BODY_PARAM_IDENTIFIER, "/cursor"))
+ mock_get_value_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'get_value_by_json_pointer', return_value="initial_form_cursor")
+
+ result = CursorPagination._get_initial_cursor_value(
+ mock_request_builder_with_form_params, "$request.body#/cursor")
+ mock_split_into_parts.assert_called_once_with("$request.body#/cursor")
+ mock_get_value_by_json_pointer.assert_called_once_with(
+ mock_request_builder_with_form_params.form_params, "/cursor")
+ assert result == "initial_form_cursor"
+
+ def test_get_initial_cursor_value_headers(self, mocker, mock_request_builder):
+ mock_split_into_parts = mocker.patch.object(ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.HEADER_PARAMS_IDENTIFIER, "/cursor"))
+ mock_get_value_by_json_pointer = mocker.patch.object(ApiHelper, 'get_value_by_json_pointer', return_value="initial_header_cursor")
+
+ result = CursorPagination._get_initial_cursor_value(mock_request_builder, "$request.headers#/cursor")
+ mock_split_into_parts.assert_called_once_with("$request.headers#/cursor")
+ mock_get_value_by_json_pointer.assert_called_once_with(mock_request_builder.header_params, "/cursor")
+ assert result == "initial_header_cursor"
+
+ def test_get_initial_cursor_value_invalid_prefix(self, mocker, mock_request_builder):
+ mock_split_into_parts = mocker.patch.object(ApiHelper, 'split_into_parts', return_value=("invalid_prefix", "some_field"))
+ # Ensure get_value_by_json_pointer is not called for invalid prefixes
+ mock_get_value_by_json_pointer = mocker.patch.object(ApiHelper, 'get_value_by_json_pointer')
+
+ result = CursorPagination._get_initial_cursor_value(mock_request_builder, "invalid_prefix.some_field")
+ mock_split_into_parts.assert_called_once_with("invalid_prefix.some_field")
+ mock_get_value_by_json_pointer.assert_not_called()
+ assert result is None
diff --git a/tests/apimatic_core/pagination_tests/strategies/test_link_pagination.py b/tests/apimatic_core/pagination_tests/strategies/test_link_pagination.py
new file mode 100644
index 0000000..d85b147
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/strategies/test_link_pagination.py
@@ -0,0 +1,163 @@
+import pytest
+
+from apimatic_core.utilities.api_helper import ApiHelper
+from apimatic_core.request_builder import RequestBuilder
+from apimatic_core.pagination.strategies.link_pagination import LinkPagination
+
+
+class TestLinkPagination:
+
+ @pytest.fixture
+ def mock_metadata_wrapper(self, mocker):
+ return mocker.Mock(name="metadata_wrapper_mock")
+
+ @pytest.fixture
+ def mock_request_builder(self, mocker):
+ # A simple mock for RequestBuilder that can be cloned
+ class MockRequestBuilder(RequestBuilder):
+
+ @property
+ def query_params(self):
+ return self._query_params
+
+ def __init__(self, query_params=None):
+ super().__init__()
+ self._query_params = query_params if query_params is not None else {}
+
+ def clone_with(self, **kwargs):
+ new_rb = MockRequestBuilder()
+ # Copy existing attributes
+ new_rb._query_params = self.query_params.copy()
+
+ # Apply updates from kwargs
+ if 'query_params' in kwargs:
+ new_rb._query_params = kwargs['query_params']
+ return new_rb
+
+ rb = MockRequestBuilder(query_params={"initial_param": "initial_value"})
+ return rb
+
+ @pytest.fixture
+ def mock_last_response_with_link(self, mocker):
+ response = mocker.Mock()
+ response.text = '{"data": [{"id": 1}], "links": {"next": "https://api.example.com/data?page=2&limit=10"}}'
+ response.headers = {'Content-Type': 'application/json'}
+ return response
+
+ @pytest.fixture
+ def mock_last_response_no_link(self, mocker):
+ response = mocker.Mock()
+ response.text = '{"data": [{"id": 1}]}' # No 'next' link
+ response.headers = {'Content-Type': 'application/json'}
+ return response
+
+ @pytest.fixture
+ def mock_paginated_data_with_link(self, mocker, mock_request_builder, mock_last_response_with_link):
+ paginated_data = mocker.Mock()
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.last_response = mock_last_response_with_link
+ return paginated_data
+
+ @pytest.fixture
+ def mock_paginated_data_no_link(self, mocker, mock_request_builder, mock_last_response_no_link):
+ paginated_data = mocker.Mock()
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.last_response = mock_last_response_no_link
+ return paginated_data
+
+ @pytest.fixture
+ def mock_paginated_data_initial_call(self, mocker, mock_request_builder):
+ paginated_data = mocker.Mock()
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.last_response = None # Simulates the first call
+ return paginated_data
+
+ # Test __init__
+ def test_init_success(self, mock_metadata_wrapper):
+ lp = LinkPagination(next_link_pointer="$response.body#/links/next", metadata_wrapper=mock_metadata_wrapper)
+ assert lp._next_link_pointer == "$response.body#/links/next"
+ assert lp._metadata_wrapper == mock_metadata_wrapper
+ assert lp._next_link is None
+
+ def test_init_next_link_pointer_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Next link pointer for cursor based pagination cannot be None"):
+ LinkPagination(next_link_pointer=None, metadata_wrapper=mock_metadata_wrapper)
+
+ def test_init_metadata_wrapper_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Metadata wrapper for the pagination cannot be None"):
+ LinkPagination(next_link_pointer=None, metadata_wrapper=None)
+
+ # Test apply
+ def test_apply_initial_call_returns_original_request_builder(
+ self, mock_paginated_data_initial_call, mock_metadata_wrapper):
+ lp = LinkPagination(next_link_pointer="$response.body#/links/next", metadata_wrapper=mock_metadata_wrapper)
+ result = lp.apply(mock_paginated_data_initial_call)
+ assert result == mock_paginated_data_initial_call.request_builder
+ assert lp._next_link is None
+
+ def test_apply_with_next_link_found(self, mocker, mock_paginated_data_with_link, mock_request_builder):
+ # Patch ApiHelper.resolve_response_pointer and ApiHelper.get_query_parameters
+ mock_resolve_response_pointer = mocker.patch.object(
+ ApiHelper, 'resolve_response_pointer',
+ return_value="https://api.example.com/data?page=2&limit=10"
+ )
+ mock_get_query_parameters = mocker.patch.object(
+ ApiHelper, 'get_query_parameters',
+ return_value={"page": "2", "limit": "10"}
+ )
+
+ lp = LinkPagination(next_link_pointer="$response.body#/links/next", metadata_wrapper=mocker.Mock())
+ result_rb = lp.apply(mock_paginated_data_with_link)
+
+ mock_resolve_response_pointer.assert_called_once_with(
+ "$response.body#/links/next",
+ mock_paginated_data_with_link.last_response.text,
+ mock_paginated_data_with_link.last_response.headers
+ )
+ mock_get_query_parameters.assert_called_once_with("https://api.example.com/data?page=2&limit=10")
+
+ assert lp._next_link == "https://api.example.com/data?page=2&limit=10"
+ assert result_rb is not mock_request_builder # Should be a cloned instance
+ assert result_rb.query_params == {"initial_param": "initial_value", "page": "2", "limit": "10"}
+
+ def test_apply_no_next_link_found_in_response(self, mocker, mock_paginated_data_no_link, mock_metadata_wrapper):
+ # Patch ApiHelper.resolve_response_pointer to return None
+ mock_resolve_response_pointer = mocker.patch.object(ApiHelper, 'resolve_response_pointer', return_value=None)
+ # Ensure get_query_parameters is NOT called
+ spy_get_query_parameters = mocker.spy(ApiHelper, 'get_query_parameters')
+
+ lp = LinkPagination(next_link_pointer="$response.body#/links/next", metadata_wrapper=mock_metadata_wrapper)
+ result = lp.apply(mock_paginated_data_no_link)
+
+ mock_resolve_response_pointer.assert_called_once_with(
+ "$response.body#/links/next",
+ mock_paginated_data_no_link.last_response.text,
+ mock_paginated_data_no_link.last_response.headers
+ )
+ assert lp._next_link is None
+ spy_get_query_parameters.assert_not_called()
+ assert result is None
+
+ # Test apply_metadata_wrapper
+ def test_apply_metadata_wrapper(self, mock_metadata_wrapper, mocker):
+ mock_metadata_wrapper.return_value = "wrapped_response_with_link"
+
+ lp = LinkPagination(
+ next_link_pointer="$response.body#/links/next",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+ lp._next_link = "https://api.example.com/data?page=2" # Set a next link for the test
+ mock_paged_response = mocker.Mock()
+
+ result = lp.apply_metadata_wrapper(mock_paged_response)
+
+ mock_metadata_wrapper.assert_called_once_with(mock_paged_response, "https://api.example.com/data?page=2")
+ assert result == "wrapped_response_with_link"
+
+ def test_is_applicable_for_none_response(self, mock_metadata_wrapper):
+ lp = LinkPagination(
+ next_link_pointer="$response.body#/next_cursor",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+ result = lp.is_applicable(None)
+ assert result == True
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/strategies/test_offset_pagination.py b/tests/apimatic_core/pagination_tests/strategies/test_offset_pagination.py
new file mode 100644
index 0000000..df71662
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/strategies/test_offset_pagination.py
@@ -0,0 +1,200 @@
+import pytest
+
+from apimatic_core.pagination.paginated_data import PaginatedData
+from apimatic_core.pagination.strategies.offset_pagination import OffsetPagination
+from tests.apimatic_core.pagination_tests.strategies.strategy_base import StrategyBase
+from tests.apimatic_core.pagination_tests.test_pagination_strategy import MockRequestBuilder
+
+
+class TestOffsetPagination(StrategyBase):
+
+ @pytest.fixture
+ def mock_metadata_wrapper(self, mocker):
+ return mocker.Mock(name="metadata_wrapper_mock")
+
+ @pytest.fixture
+ def mock_request_builder(self, mocker):
+ rb = MockRequestBuilder(
+ template_params={"offset": {"value" : 5, "encode": True}},
+ query_params={"offset": 10, "limit": 20},
+ header_params={"offset": 15}
+ )
+ return rb
+
+ @pytest.fixture
+ def mock_request_builder_with_json_body(self, mocker):
+ rb = MockRequestBuilder(
+ template_params={"offset": {"value" : 5, "encode": True}},
+ query_params={"offset": 10, "limit": 20},
+ header_params={"offset": 15},
+ body_param={"offset": 15}
+ )
+ return rb
+
+ @pytest.fixture
+ def mock_request_builder_with_form_params(self, mocker):
+ rb = MockRequestBuilder(
+ template_params={"offset": {"value" : 5, "encode": True}},
+ query_params={"offset": 10, "limit": 20},
+ header_params={"offset": 15},
+ form_params={"offset": 15}
+ )
+ return rb
+
+ @pytest.fixture
+ def mock_last_response(self, mocker):
+ response = mocker.Mock()
+ response.text = '{"data": [{"id": 1}], "total_count": 100}'
+ response.headers = {'Content-Type': 'application/json'}
+ return response
+
+ @pytest.fixture
+ def mock_paginated_data_initial_call(self, mocker, mock_request_builder):
+ paginated_data = mocker.Mock(spec=PaginatedData)
+ paginated_data.last_response = None
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.page_size = 0
+ return paginated_data
+
+ @pytest.fixture
+ def mock_paginated_data_subsequent_call(self, mocker, mock_request_builder, mock_last_response):
+ paginated_data = mocker.Mock(spec=PaginatedData)
+ paginated_data.last_response = mock_last_response
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.page_size = 10
+ return paginated_data
+
+ def _create_offset_pagination_instance(self, input_value, metadata_wrapper):
+ """Helper to create an OffsetPagination instance."""
+ return OffsetPagination(input_=input_value, metadata_wrapper=metadata_wrapper)
+
+ # --- Test __init__ ---
+ def test_init_success(self, mock_metadata_wrapper):
+ op = self._create_offset_pagination_instance("$request.query#/offset", mock_metadata_wrapper)
+ assert op._input == "$request.query#/offset"
+ assert op._metadata_wrapper == mock_metadata_wrapper
+ assert op._offset == 0
+
+ def test_init_input_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Input pointer for offset based pagination cannot be None"):
+ self._create_offset_pagination_instance(None, mock_metadata_wrapper)
+
+ def test_init_metadata_wrapper_none_raises_error(self):
+ with pytest.raises(ValueError, match="Metadata wrapper for the pagination cannot be None"):
+ self._create_offset_pagination_instance("$request.query#/offset", None)
+
+ # --- Test apply ---
+ def test_apply_initial_call_with_offset_from_query(self, mocker, mock_paginated_data_initial_call,
+ mock_request_builder):
+ mock_get_initial_request_param_value = mocker.patch.object(OffsetPagination, '_get_initial_request_param_value', return_value=100)
+
+ op = self._create_offset_pagination_instance("$request.query#/offset", mocker.Mock())
+ result_rb = op.apply(mock_paginated_data_initial_call)
+
+ mock_get_initial_request_param_value.assert_called_once_with(mock_request_builder, "$request.query#/offset")
+ assert op._offset == 100
+ assert result_rb == mock_request_builder
+
+ def test_apply_subsequent_call_increments_offset_and_updates_builder(self, mocker,
+ mock_paginated_data_subsequent_call,
+ mock_request_builder):
+ op = self._create_offset_pagination_instance("$request.query#/offset", mocker.Mock())
+ op._offset = 10
+
+ mock_get_updated_request_builder = mocker.patch.object(
+ OffsetPagination, 'get_updated_request_builder',
+ return_value=mock_request_builder.clone_with(query_params={"offset": 20, "limit": 10})
+ )
+
+ result_rb = op.apply(mock_paginated_data_subsequent_call)
+
+ assert op._offset == 20
+ mock_get_updated_request_builder.assert_called_once_with(
+ mock_request_builder, "$request.query#/offset", 20
+ )
+ assert result_rb.query_params["offset"] == 20
+
+ # --- Test apply_metadata_wrapper ---
+ def test_apply_metadata_wrapper(self, mock_metadata_wrapper, mocker):
+ mock_metadata_wrapper.return_value = "wrapped_response_with_offset"
+
+ op = self._create_offset_pagination_instance("$request.query#/offset", mock_metadata_wrapper)
+ op._offset = 50
+ mock_page_response = mocker.Mock()
+
+ result = op.apply_metadata_wrapper(mock_page_response)
+
+ mock_metadata_wrapper.assert_called_once_with(mock_page_response, 50)
+ assert result == "wrapped_response_with_offset"
+
+ # --- Test _get_initial_request_param_value ---
+ @pytest.mark.parametrize(
+ "input_pointer, initial_params, expected_value, json_pointer_return_value",
+ [
+ ("$request.path#/offset", {"offset": {"value" : 5, "encode": True}}, 50, "50"),
+ ("$request.query#/offset", {"offset": 100, "limit": 20}, 100, "100"),
+ ("$request.headers#/offset", {"offset": 200}, 200, "200"),
+ ("$request.body#/offset", {"offset": 200}, 200, "200"),
+ ("$request.query#/offset", {"limit": 20}, 0, None), # No value found
+ ("invalid_prefix#/offset", {"offset": 10}, 0, "10"), # Invalid prefix, should default to 0
+ ]
+ )
+ def test_get_initial_offset_various_scenarios(self, mocker, mock_request_builder, mock_metadata_wrapper,
+ input_pointer, initial_params, expected_value, json_pointer_return_value):
+ self.assert_initial_param_extraction(
+ mocker,
+ mock_request_builder,
+ mock_metadata_wrapper,
+ input_pointer,
+ initial_params,
+ expected_value,
+ json_pointer_return_value,
+ default_value=0,
+ pagination_instance_creator=self._create_offset_pagination_instance
+ )
+
+ @pytest.mark.parametrize(
+ "input_pointer, initial_params, expected_value, json_pointer_return_value",
+ [
+ ("$request.body#/offset", {"offset": 200}, 200, "200"),
+ ("$request.body#/offset", {"limit": 20}, 0, None),
+ ("invalid_prefix#/offset", {"offset": 10}, 0, "10"),
+ ]
+ )
+ def test_get_initial_json_body_offset_various_scenarios(
+ self, mocker, mock_request_builder_with_json_body, mock_metadata_wrapper, input_pointer, initial_params,
+ expected_value, json_pointer_return_value):
+ self.assert_initial_param_extraction(
+ mocker,
+ mock_request_builder_with_json_body,
+ mock_metadata_wrapper,
+ input_pointer,
+ initial_params,
+ expected_value,
+ json_pointer_return_value,
+ default_value=0,
+ pagination_instance_creator=self._create_offset_pagination_instance
+ )
+
+ @pytest.mark.parametrize(
+ "input_pointer, initial_params, expected_value, json_pointer_return_value",
+ [
+ ("$request.body#/offset", {"offset": 200}, 200, "200"),
+ ("$request.body#/offset", {"limit": 20}, 0, None),
+ ("invalid_prefix#/offset", {"offset": 10}, 0, "10"),
+ ]
+ )
+ def test_get_initial_form_body_offset_various_scenarios(
+ self, mocker, mock_request_builder_with_form_params, mock_metadata_wrapper, input_pointer, initial_params,
+ expected_value, json_pointer_return_value):
+ self.assert_initial_param_extraction(
+ mocker,
+ mock_request_builder_with_form_params,
+ mock_metadata_wrapper,
+ input_pointer,
+ initial_params,
+ expected_value,
+ json_pointer_return_value,
+ default_value=0,
+ pagination_instance_creator=self._create_offset_pagination_instance
+ )
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/strategies/test_page_pagination.py b/tests/apimatic_core/pagination_tests/strategies/test_page_pagination.py
new file mode 100644
index 0000000..2d520f8
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/strategies/test_page_pagination.py
@@ -0,0 +1,189 @@
+import pytest
+
+from apimatic_core.pagination.paginated_data import PaginatedData
+from apimatic_core.pagination.strategies.page_pagination import PagePagination
+from apimatic_core.request_builder import RequestBuilder
+from tests.apimatic_core.pagination_tests.strategies.strategy_base import StrategyBase
+
+
+class TestPagePagination(StrategyBase):
+
+ @pytest.fixture
+ def mock_metadata_wrapper(self, mocker):
+ return mocker.Mock(name="metadata_wrapper_mock")
+
+ @pytest.fixture
+ def mock_request_builder(self, mocker):
+ class MockRequestBuilder(RequestBuilder):
+ @property
+ def template_params(self):
+ return self._template_params
+
+ @property
+ def query_params(self):
+ return self._query_params
+
+ @property
+ def header_params(self):
+ return self._header_params
+
+ def __init__(self, template_params=None, query_params=None, header_params=None):
+ super().__init__()
+ self._template_params = template_params if template_params is not None else {}
+ self._query_params = query_params if query_params is not None else {}
+ self._header_params = header_params if header_params is not None else {}
+
+ def clone_with(self, **kwargs):
+ new_rb = MockRequestBuilder()
+ new_rb._template_params = self.template_params.copy()
+ new_rb._query_params = self.query_params.copy()
+ new_rb._header_params = self.header_params.copy()
+
+ if 'template_params' in kwargs:
+ new_rb.template_params.update(kwargs['template_params'])
+ if 'query_params' in kwargs:
+ new_rb.query_params.update(kwargs['query_params'])
+ if 'header_params' in kwargs:
+ new_rb.header_params.update(kwargs['header_params'])
+ return new_rb
+
+ rb = MockRequestBuilder(
+ template_params={"page": 1},
+ query_params={"page": 2, "limit": 10},
+ header_params={"page": 3}
+ )
+ return rb
+
+ @pytest.fixture
+ def mock_last_response(self, mocker):
+ response = mocker.Mock()
+ response.text = '{"data": [{"id": 1}]}'
+ response.headers = {'Content-Type': 'application/json'}
+ return response
+
+ @pytest.fixture
+ def mock_paginated_data_initial_call(self, mocker, mock_request_builder):
+ paginated_data = mocker.Mock(spec=PaginatedData)
+ paginated_data.last_response = None
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.page_size = 0
+ return paginated_data
+
+ @pytest.fixture
+ def mock_paginated_data_subsequent_call_with_results(self, mocker, mock_request_builder, mock_last_response):
+ paginated_data = mocker.Mock(spec=PaginatedData)
+ paginated_data.last_response = mock_last_response
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.page_size = 5
+ return paginated_data
+
+ @pytest.fixture
+ def mock_paginated_data_subsequent_call_no_results(self, mocker, mock_request_builder, mock_last_response):
+ paginated_data = mocker.Mock(spec=PaginatedData)
+ paginated_data.last_response = mock_last_response
+ paginated_data.request_builder = mock_request_builder
+ paginated_data.page_size = 0
+ return paginated_data
+
+ def _create_page_pagination_instance(self, input_value, metadata_wrapper):
+ return PagePagination(input_=input_value, metadata_wrapper=metadata_wrapper)
+
+ def test_init_success(self, mock_metadata_wrapper):
+ pp = self._create_page_pagination_instance("$request.query#/page", mock_metadata_wrapper)
+ assert pp._input == "$request.query#/page"
+ assert pp._metadata_wrapper == mock_metadata_wrapper
+ assert pp._page_number == 1
+
+ def test_init_input_none_raises_error(self, mock_metadata_wrapper):
+ with pytest.raises(ValueError, match="Input pointer for page based pagination cannot be None"):
+ self._create_page_pagination_instance(None, mock_metadata_wrapper)
+
+ def test_init_metadata_wrapper_none_raises_error(self):
+ with pytest.raises(ValueError, match="Metadata wrapper for the pagination cannot be None"):
+ self._create_page_pagination_instance("$request.query#/page", None)
+
+ def test_apply_initial_call_with_page_from_query(self, mocker, mock_paginated_data_initial_call,
+ mock_request_builder, mock_metadata_wrapper):
+ mock_get_initial_request_param_value = mocker.patch.object(PagePagination, '_get_initial_request_param_value', return_value=5)
+
+ pp = self._create_page_pagination_instance("$request.query#/page", mock_metadata_wrapper)
+ result_rb = pp.apply(mock_paginated_data_initial_call)
+
+ mock_get_initial_request_param_value.assert_called_once_with(mock_request_builder, "$request.query#/page", 1)
+ assert pp._page_number == 5
+ assert result_rb == mock_request_builder
+
+ def test_apply_subsequent_call_increments_page_with_results(self, mocker,
+ mock_paginated_data_subsequent_call_with_results,
+ mock_request_builder):
+ pp = self._create_page_pagination_instance("$request.query#/page", mocker.Mock())
+ pp._page_number = 2
+
+ mock_get_updated_request_builder = mocker.patch.object(
+ PagePagination, 'get_updated_request_builder',
+ return_value=mock_request_builder.clone_with(query_params={"page": 3, "limit": 10})
+ )
+
+ result_rb = pp.apply(mock_paginated_data_subsequent_call_with_results)
+
+ assert pp._page_number == 3
+ mock_get_updated_request_builder.assert_called_once_with(
+ mock_request_builder, "$request.query#/page", 3
+ )
+ assert result_rb.query_params["page"] == 3
+
+ def test_apply_subsequent_call_does_not_increment_page_with_no_results(self, mocker,
+ mock_paginated_data_subsequent_call_no_results,
+ mock_request_builder):
+ pp = self._create_page_pagination_instance("$request.query#/page", mocker.Mock())
+ pp._page_number = 3
+
+ mock_get_updated_request_builder = mocker.patch.object(
+ PagePagination, 'get_updated_request_builder'
+ )
+
+ pp.apply(mock_paginated_data_subsequent_call_no_results)
+
+ assert pp._page_number == 2
+ mock_get_updated_request_builder.assert_called_once_with(
+ mock_request_builder, "$request.query#/page", 2
+ )
+
+ def test_apply_metadata_wrapper(self, mock_metadata_wrapper, mocker):
+ mock_metadata_wrapper.return_value = "wrapped_response_with_page"
+
+ pp = self._create_page_pagination_instance(
+ input_value="$request.query#/page",
+ metadata_wrapper=mock_metadata_wrapper
+ )
+ pp._page_number = 5
+ mock_page_response = mocker.Mock()
+
+ result = pp.apply_metadata_wrapper(mock_page_response)
+
+ mock_metadata_wrapper.assert_called_once_with(mock_page_response, 5)
+ assert result == "wrapped_response_with_page"
+
+ @pytest.mark.parametrize(
+ "input_pointer, initial_params, expected_value, json_pointer_return_value",
+ [
+ ("$request.path#/page", {"page": {"value": 2, "encoded": True}}, 2, "2"),
+ ("$request.query#/page", {"page": 3, "limit": 10}, 3, "3"),
+ ("$request.headers#/page", {"page": 4}, 4, "4"),
+ ("$request.query#/page", {"limit": 10}, 0, None),
+ ("invalid_prefix#/page", {"page": 10}, 0, "10"),
+ ]
+ )
+ def test_get_initial_page_offset_various_scenarios(self, mocker, mock_request_builder, mock_metadata_wrapper,
+ input_pointer, initial_params, expected_value, json_pointer_return_value):
+ self.assert_initial_param_extraction(
+ mocker,
+ mock_request_builder,
+ mock_metadata_wrapper,
+ input_pointer,
+ initial_params,
+ expected_value,
+ json_pointer_return_value,
+ default_value=0,
+ pagination_instance_creator=self._create_page_pagination_instance
+ )
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/test_paginated_data.py b/tests/apimatic_core/pagination_tests/test_paginated_data.py
new file mode 100644
index 0000000..281834f
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/test_paginated_data.py
@@ -0,0 +1,234 @@
+import pytest
+
+from apimatic_core.configurations.global_configuration import GlobalConfiguration
+from apimatic_core.http.configurations.http_client_configuration import HttpClientConfiguration
+from apimatic_core.pagination.paginated_data import PaginatedData
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.request_builder import RequestBuilder
+
+
+class TestPaginatedData:
+
+ @pytest.fixture
+ def mock_api_call(self, mocker):
+ mock_api_call_instance = mocker.Mock()
+ mock_api_call_instance.get_pagination_strategies = []
+ mock_api_call_instance.request_builder = mocker.Mock(spec=RequestBuilder)
+ mock_api_call_instance.request_builder.query_params = {"initial": "value"} # Example
+
+ # Mock GlobalConfiguration and HttpClientConfiguration for deepcopy
+ mock_http_client_config = mocker.Mock(spec=HttpClientConfiguration)
+ mock_http_client_config.http_callback = None # Default
+ mock_http_client_config.clone.return_value = mock_http_client_config # Return self on clone
+
+ mock_global_config = mocker.Mock(spec=GlobalConfiguration)
+ mock_global_config.get_http_client_configuration.return_value = mock_http_client_config
+ mock_global_config.clone_with.return_value = mock_global_config # Return self on clone
+
+ mock_api_call_instance.global_configuration = mock_global_config
+
+ # Mock the clone method of ApiCall
+ mock_api_call_instance.clone.return_value = mock_api_call_instance
+ return mock_api_call_instance
+
+ @pytest.fixture
+ def mock_paginated_items_converter(self, mocker):
+ # A simple converter that returns a list of items from a mock body
+ def converter(body):
+ if body and 'items' in body:
+ return body['items']
+ return []
+
+ return mocker.Mock(side_effect=converter)
+
+ @pytest.fixture
+ def mock_paginated_data_instance(self, mock_api_call, mock_paginated_items_converter):
+ # We need to create a real instance for public method tests
+ # We will mock internal dependencies within the tests as needed.
+ return PaginatedData(mock_api_call, mock_paginated_items_converter)
+
+ @pytest.fixture
+ def mock_pagination_strategy(self, mocker):
+ strategy = mocker.Mock(spec=PaginationStrategy)
+ # Default behavior: apply returns a request builder, apply_metadata_wrapper returns the response
+ strategy.apply.return_value = mocker.Mock(spec=RequestBuilder)
+ strategy.apply_metadata_wrapper.side_effect = lambda response: response
+ return strategy
+
+ @pytest.fixture
+ def mock_http_response(self, mocker):
+ response = mocker.Mock()
+ response.body = {"items": [{"id": 1, "name": "Item 1"}]}
+ return response
+
+ @pytest.fixture
+ def mock_http_response_empty_items(self, mocker):
+ response = mocker.Mock()
+ response.body = {"items": []}
+ return response
+
+ def test_init_paginated_items_converter_none_raises_error(self, mock_api_call):
+ with pytest.raises(ValueError, match="paginated_items_converter cannot be None"):
+ PaginatedData(api_call=mock_api_call, paginated_items_converter=None)
+
+ # Test __next__
+ def test_next_iterates_current_items(self, mock_paginated_data_instance):
+ mock_paginated_data_instance._items = ["item1", "item2"]
+ mock_paginated_data_instance._page_size = 2
+ mock_paginated_data_instance._current_index = 0
+
+ assert next(mock_paginated_data_instance) == "item1"
+ assert mock_paginated_data_instance._current_index == 1
+ assert next(mock_paginated_data_instance) == "item2"
+ assert mock_paginated_data_instance._current_index == 2
+
+ def test_next_fetches_next_page_when_current_exhausted(self, mocker, mock_paginated_data_instance,
+ mock_http_response):
+ mock_paginated_data_instance._items = ["old_item"]
+ mock_paginated_data_instance._page_size = 1
+ mock_paginated_data_instance._current_index = 1 # Simulate exhausted
+
+ # Mock _fetch_next_page (private method)
+ mock_fetch_next_page = mocker.patch.object(PaginatedData, '_fetch_next_page', return_value=mock_http_response)
+
+ # First call to next should trigger fetch_next_page
+ first_new_item = next(mock_paginated_data_instance)
+
+ mock_fetch_next_page.assert_called_once()
+ mock_paginated_data_instance._paginated_items_converter.assert_called_once_with(mock_http_response.body)
+ assert first_new_item == {"id": 1, "name": "Item 1"}
+ assert mock_paginated_data_instance._page_size == 1
+ assert mock_paginated_data_instance._current_index == 1
+ assert mock_paginated_data_instance._items == [{"id": 1, "name": "Item 1"}]
+
+ def test_next_raises_stopiteration_when_no_more_items(self, mocker, mock_paginated_data_instance,
+ mock_http_response_empty_items):
+ mock_paginated_data_instance._items = []
+ mock_paginated_data_instance._page_size = 0
+ mock_paginated_data_instance._current_index = 0
+
+ # Mock _fetch_next_page (private method) to return a response with empty items
+ mocker.patch.object(PaginatedData, '_fetch_next_page', return_value=mock_http_response_empty_items)
+
+ with pytest.raises(StopIteration):
+ next(mock_paginated_data_instance)
+
+ # Also check when _fetch_next_page returns an empty list (simulating no more strategies)
+ mocker.patch.object(PaginatedData, '_fetch_next_page',
+ return_value=[]) # _fetch_next_page returns [] if no strategy applies
+ mock_paginated_data_instance._items = []
+ mock_paginated_data_instance._page_size = 0
+ mock_paginated_data_instance._current_index = 0
+ with pytest.raises(StopIteration):
+ next(mock_paginated_data_instance)
+
+ # Test pages
+ def test_pages_yields_paginated_responses(self, mocker, mock_paginated_data_instance, mock_pagination_strategy,
+ mock_http_response, mock_http_response_empty_items):
+ # Mock _get_new_self_instance (private method)
+ # Create a new mock instance for the independent iterator used by pages()
+ pages_internal_paginated_data = mocker.Mock(spec=PaginatedData)
+ mocker.patch.object(PaginatedData, 'clone', return_value=pages_internal_paginated_data)
+
+ # Configure the *internal* mock instance's _fetch_next_page
+ pages_internal_paginated_data._fetch_next_page = mocker.Mock(
+ side_effect=[
+ mock_http_response, # First page
+ mocker.Mock(body={"items": [{"id": 2, "name": "Item 2"}]}), # Second page
+ mock_http_response_empty_items # Signal end of pages
+ ]
+ )
+ # Configure the *internal* mock instance's paginated_items_converter
+ pages_internal_paginated_data._paginated_items_converter = mocker.Mock(
+ side_effect=[
+ mock_http_response.body['items'],
+ [{"id": 2, "name": "Item 2"}],
+ [] # For the empty response
+ ]
+ )
+ # Ensure _page_size is updated by the private method
+ pages_internal_paginated_data._page_size = 0 # Initial state for internal
+ pages_internal_paginated_data.last_response = None # Initial state for internal
+
+ pages_generator = mock_paginated_data_instance.pages()
+
+ # First page
+ page1 = next(pages_generator)
+ assert page1.body['items'] == [{"id": 1, "name": "Item 1"}]
+
+ # Second page
+ page2 = next(pages_generator)
+ assert page2.body['items'] == [{"id": 2, "name": "Item 2"}]
+
+ # Attempt to get third page should raise StopIteration
+ with pytest.raises(StopIteration):
+ next(pages_generator)
+
+ assert pages_internal_paginated_data._fetch_next_page.call_count == 3
+
+ def test_pages_returns_empty_generator_if_no_initial_items(self, mocker, mock_paginated_data_instance,
+ mock_http_response_empty_items):
+ # Mock _get_new_self_instance (private method)
+ pages_internal_paginated_data = mocker.Mock(spec=PaginatedData)
+ mocker.patch.object(PaginatedData, 'clone', return_value=pages_internal_paginated_data)
+
+ # Configure the *internal* mock instance's _fetch_next_page to return an empty items response immediately
+ pages_internal_paginated_data._fetch_next_page = mocker.Mock(return_value=mock_http_response_empty_items)
+ pages_internal_paginated_data._paginated_items_converter = mocker.Mock(return_value=[])
+ pages_internal_paginated_data._page_size = 0
+ pages_internal_paginated_data.last_response = None
+
+ pages_generator = mock_paginated_data_instance.pages()
+ with pytest.raises(StopIteration):
+ next(pages_generator)
+
+ def test_fetch_next_page_with_locked_strategy(self, mock_api_call, mocker):
+ paginated_data = PaginatedData(mock_api_call, paginated_items_converter=mocker.Mock())
+ strategy = mocker.Mock()
+ strategy.apply.return_value = mocker.Mock()
+ strategy.apply_metadata_wrapper.return_value = mocker.Mock()
+
+ paginated_data._locked_strategy = strategy
+
+ result = paginated_data._fetch_next_page()
+
+ strategy.apply.assert_called_once()
+ strategy.apply_metadata_wrapper.assert_called_once()
+ assert result == strategy.apply_metadata_wrapper.return_value
+
+ def test_fetch_next_page_first_strategy_successful(self, mock_api_call, mocker):
+ paginated_data = PaginatedData(mock_api_call, paginated_items_converter=mocker.Mock())
+
+ strategy = mocker.Mock()
+ strategy.apply.return_value = mocker.Mock()
+ strategy.apply_metadata_wrapper.return_value = mocker.Mock()
+ strategy.is_applicable.return_value = True
+
+ paginated_data._pagination_strategies = [strategy]
+
+ mocker.patch.object(paginated_data, "_get_locked_strategy", return_value=strategy)
+
+ result = paginated_data._fetch_next_page()
+
+ strategy.apply.assert_called_once()
+ strategy.apply_metadata_wrapper.assert_called_once()
+ assert result == strategy.apply_metadata_wrapper.return_value
+ assert paginated_data._locked_strategy == strategy
+
+ def test_fetch_next_page_all_strategies_none(self, mock_api_call, mocker):
+ paginated_data = PaginatedData(mock_api_call, paginated_items_converter=mocker.Mock())
+
+ strategy1 = mocker.Mock()
+ strategy1.apply.return_value = None
+
+ strategy2 = mocker.Mock()
+ strategy2.apply.return_value = None
+
+ paginated_data._pagination_strategies = [strategy1, strategy2]
+
+ result = paginated_data._fetch_next_page()
+
+ strategy1.apply.assert_called_once()
+ strategy2.apply.assert_called_once()
+ assert result is None
+ assert paginated_data._locked_strategy is None
\ No newline at end of file
diff --git a/tests/apimatic_core/pagination_tests/test_pagination_strategy.py b/tests/apimatic_core/pagination_tests/test_pagination_strategy.py
new file mode 100644
index 0000000..bab4fe1
--- /dev/null
+++ b/tests/apimatic_core/pagination_tests/test_pagination_strategy.py
@@ -0,0 +1,265 @@
+import pytest
+
+from apimatic_core.pagination.pagination_strategy import PaginationStrategy
+from apimatic_core.request_builder import RequestBuilder
+from apimatic_core.utilities.api_helper import ApiHelper
+
+
+class MockRequestBuilder(RequestBuilder):
+
+ @property
+ def template_params(self):
+ return self._template_params
+
+ @property
+ def query_params(self):
+ return self._query_params
+
+ @property
+ def header_params(self):
+ return self._header_params
+
+ @property
+ def body_params(self):
+ return self._body_param
+
+ @property
+ def form_params(self):
+ return self._form_params
+
+ def __init__(self, template_params=None, header_params=None, query_params=None, body_param=None, form_params=None):
+ super().__init__()
+ self._template_params = template_params if template_params is not None else {}
+ self._query_params = query_params if query_params is not None else {}
+ self._header_params = header_params if header_params is not None else {}
+ self._body_param = body_param if body_param is not None else None
+ self._form_params = form_params if form_params is not None else {}
+
+ def clone_with(
+ self, template_params=None, header_params=None, query_params=None, body_param=None,
+ form_params=None
+ ):
+ body_param = body_param if body_param is not None else self.body_params
+ # This mock clone_with will create a new instance with updated params
+ new_rb = MockRequestBuilder(
+ template_params=template_params if template_params is not None else self.template_params.copy(),
+ header_params=header_params if header_params is not None else self.header_params.copy(),
+ query_params=query_params if query_params is not None else self.query_params.copy(),
+ body_param=body_param.copy() if body_param is not None else None,
+ form_params=form_params if form_params is not None else self.form_params.copy(),
+ )
+ return new_rb
+
+class TestPaginationStrategy:
+ @pytest.fixture
+ def mock_request_builder(self):
+ rb = MockRequestBuilder(
+ template_params={"id": "user123"},
+ query_params={"page": 1, "limit": 10},
+ header_params={"X-Api-Key": "abc"},
+ )
+ return rb
+
+ @pytest.fixture
+ def mock_request_builder_with_json_body(self):
+ rb = MockRequestBuilder(
+ template_params={"id": "user123"},
+ query_params={"page": 1, "limit": 10},
+ header_params={"X-Api-Key": "abc"},
+ body_param={"data": "value"}
+ )
+ return rb
+
+ @pytest.fixture
+ def mock_request_builder_with_form_body(self):
+ rb = MockRequestBuilder(
+ template_params={"id": "user123"},
+ query_params={"page": 1, "limit": 10},
+ header_params={"X-Api-Key": "abc"},
+ form_params={"data": "value"}
+ )
+ return rb
+
+
+ # Test updating path parameters
+ def test_update_request_builder_path_param(self, mocker, mock_request_builder):
+ input_pointer = "$request.path#/id"
+ offset = "user456"
+
+ # Mock ApiHelper.split_into_parts
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.PATH_PARAMS_IDENTIFIER, "/id"))
+ # Mock ApiHelper.update_entry_by_json_pointer
+ mock_update_entry_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'update_entry_by_json_pointer',
+ side_effect=lambda data, path, value, inplace: {**data, 'id': value})
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(mock_request_builder, input_pointer, offset)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ mock_update_entry_by_json_pointer.assert_called_once_with(
+ mock_request_builder.template_params.copy(), "/id/value", offset, inplace=True
+ )
+
+ assert updated_rb is not mock_request_builder # Should return a cloned instance
+ assert updated_rb.template_params == {"id": "user456"}
+ assert updated_rb.query_params == {"page": 1, "limit": 10} # Should remain unchanged
+ assert updated_rb.header_params == {"X-Api-Key": "abc"} # Should remain unchanged
+
+ # Test updating query parameters
+ def test_update_request_builder_query_param(self, mocker, mock_request_builder):
+ input_pointer = "$request.query#/page"
+ offset = 5
+
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.QUERY_PARAMS_IDENTIFIER, "/page"))
+ mock_update_entry_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'update_entry_by_json_pointer',
+ side_effect=lambda data, path, value, inplace: {**data, 'page': value})
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(mock_request_builder, input_pointer, offset)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ mock_update_entry_by_json_pointer.assert_called_once_with(
+ mock_request_builder.query_params.copy(), "/page", offset, inplace=True
+ )
+
+ assert updated_rb is not mock_request_builder
+ assert updated_rb.template_params == {"id": "user123"}
+ assert updated_rb.query_params == {"page": 5, "limit": 10}
+ assert updated_rb.header_params == {"X-Api-Key": "abc"}
+
+
+ # Test updating header parameters
+ def test_update_request_builder_header_param(self, mocker, mock_request_builder):
+ input_pointer = "$request.headers#/X-Api-Key"
+ offset = "xyz"
+
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.HEADER_PARAMS_IDENTIFIER, "/X-Api-Key"))
+ mock_update_entry_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'update_entry_by_json_pointer',
+ side_effect=lambda data, path, value, inplace: {**data, 'X-Api-Key': value})
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(mock_request_builder, input_pointer, offset)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ mock_update_entry_by_json_pointer.assert_called_once_with(
+ mock_request_builder.header_params.copy(), "/X-Api-Key", offset, inplace=True
+ )
+
+ assert updated_rb is not mock_request_builder
+ assert updated_rb.template_params == {"id": "user123"}
+ assert updated_rb.query_params == {"page": 1, "limit": 10}
+ assert updated_rb.header_params == {"X-Api-Key": "xyz"}
+
+ def test_update_request_builder_json_body_param(self, mocker, mock_request_builder_with_json_body):
+ input_pointer = "$request.body#/data"
+ updated_value = "changed_value"
+
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.BODY_PARAM_IDENTIFIER, "/data"))
+ mock_update_entry_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'update_entry_by_json_pointer',
+ side_effect=lambda data, path, value, inplace: {**data, 'data': value})
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(mock_request_builder_with_json_body, input_pointer, updated_value)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ mock_update_entry_by_json_pointer.assert_called_once_with(
+ mock_request_builder_with_json_body.body_params.copy(), "/data", updated_value, inplace=True
+ )
+
+ assert updated_rb is not mock_request_builder_with_json_body
+ assert updated_rb.template_params == {"id": "user123"}
+ assert updated_rb.query_params == {"page": 1, "limit": 10}
+ assert updated_rb.header_params == {"X-Api-Key": "abc"}
+ assert updated_rb.body_params == {"data": "changed_value"}
+
+ def test_update_request_builder_form_body_param(self, mocker, mock_request_builder_with_form_body):
+ input_pointer = "$request.body#/data"
+ updated_value = "changed_value"
+
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.BODY_PARAM_IDENTIFIER, "/data"))
+ mock_update_entry_by_json_pointer = mocker.patch.object(
+ ApiHelper, 'update_entry_by_json_pointer',
+ side_effect=lambda data, path, value, inplace: {**data, 'data': value})
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(
+ mock_request_builder_with_form_body, input_pointer, updated_value)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ mock_update_entry_by_json_pointer.assert_called_once_with(
+ mock_request_builder_with_form_body.form_params.copy(), "/data", updated_value, inplace=True
+ )
+
+ assert updated_rb is not mock_request_builder_with_form_body
+ assert updated_rb.template_params == {"id": "user123"}
+ assert updated_rb.query_params == {"page": 1, "limit": 10}
+ assert updated_rb.header_params == {"X-Api-Key": "abc"}
+ assert updated_rb.form_params == {"data": "changed_value"}
+
+
+ # Test with an invalid input pointer prefix
+ def test_update_request_builder_invalid_prefix(self, mocker, mock_request_builder):
+ input_pointer = "invalid.prefix#/some_field"
+ offset = "new_value"
+
+ mock_split_into_parts = mocker.patch.object(ApiHelper, 'split_into_parts', return_value=("invalid.prefix", "/some_field"))
+ # Ensure update_entry_by_json_pointer is NOT called
+ spy_update_entry_by_json_pointer = mocker.spy(ApiHelper, 'update_entry_by_json_pointer')
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(mock_request_builder, input_pointer, offset)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ spy_update_entry_by_json_pointer.assert_not_called()
+
+ assert updated_rb is not mock_request_builder # Still returns a cloned instance
+ # Original parameters should be passed to clone_with, effectively returning an unchanged copy
+ assert updated_rb.template_params == mock_request_builder.template_params
+ assert updated_rb.query_params == mock_request_builder.query_params
+ assert updated_rb.header_params == mock_request_builder.header_params
+ assert updated_rb.body_params == mock_request_builder.body_params
+ assert updated_rb.form_params == mock_request_builder.form_params
+
+
+ # Test when the original parameter dict is empty
+ def test_update_request_builder_empty_params(self, mocker):
+ mock_rb_empty = mocker.Mock(spec=RequestBuilder)
+ mock_rb_empty.template_params = {}
+ mock_rb_empty.query_params = {}
+ mock_rb_empty.header_params = {}
+ mock_rb_empty.body_params = {}
+ mock_rb_empty.form_params = {}
+ # Make mock_rb_empty's clone_with method behave like our custom mock
+ mock_rb_empty.clone_with.side_effect = \
+ lambda template_params=None, query_params=None, header_params=None, body_param=None, form_params=None:\
+ MockRequestBuilder(
+ template_params=template_params if template_params is not None else {},
+ query_params=query_params if query_params is not None else {},
+ header_params=header_params if header_params is not None else {},
+ body_param=body_param if body_param is not None else None,
+ form_params=form_params if form_params is not None else {}
+ )
+
+ input_pointer = "$request.query#/offset"
+ offset = 0
+
+ mock_split_into_parts = mocker.patch.object(
+ ApiHelper, 'split_into_parts', return_value=(PaginationStrategy.QUERY_PARAMS_IDENTIFIER, "/offset"))
+ mock_update_entry_by_json_pointer = mocker.patch.object(ApiHelper, 'update_entry_by_json_pointer',
+ side_effect=lambda data, path, value, inplace: {**data, 'offset': value})
+
+ updated_rb = PaginationStrategy.get_updated_request_builder(mock_rb_empty, input_pointer, offset)
+
+ mock_split_into_parts.assert_called_once_with(input_pointer)
+ mock_update_entry_by_json_pointer.assert_called_once_with(
+ {}, "/offset", offset, inplace=True # Should be an empty dict passed initially
+ )
+
+ assert updated_rb.query_params == {"offset": 0}
+ assert updated_rb.template_params == {}
+ assert updated_rb.header_params == {}
+ assert updated_rb.body_params == {}
+ assert updated_rb.form_params == {}
\ No newline at end of file
diff --git a/tests/apimatic_core/request_builder_tests/test_request_builder.py b/tests/apimatic_core/request_builder_tests/test_request_builder.py
index 7f84b20..ddbd78f 100644
--- a/tests/apimatic_core/request_builder_tests/test_request_builder.py
+++ b/tests/apimatic_core/request_builder_tests/test_request_builder.py
@@ -176,62 +176,68 @@ def test_additional_query_params(self, input_additional_query_params_value, expe
assert http_request.query_url == 'http://localhost:3000/test?{}'.format(expected_additional_query_params_value)
@pytest.mark.parametrize('input_local_header_param_value, expected_local_header_param_value', [
- ('string', {'header_param': 'string'}),
- (500, {'header_param': 500}),
- (500.12, {'header_param': 500.12}),
- (str(date(1994, 2, 13)), {'header_param': '1994-02-13'}),
+ ('string', {'header': 'string'}),
+ (200, {'header': '200'}),
+ (200.12, {'header': '200.12'}),
+ (str(date(1994, 2, 13)), {'header': '1994-02-13'}),
(ApiHelper.UnixDateTime.from_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': 761117415}),
+ {'header': '761117415'}),
(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': '{}'.format(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
+ {'header': '{}'.format(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': '{}'.format(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
- ([1, 2, 3, 4], {'header_param': [1, 2, 3, 4]})
+ {'header': '{}'.format(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
+ ([1, 2, 3, 4], {'header': '[1, 2, 3, 4]'}),
+ ({'alpha': 'val', 'beta': 'val'}, {'header': '{"alpha": "val", "beta": "val"}'}),
+ (Base.employee_model(), {'header': ApiHelper.json_serialize(Base.employee_model())})
])
def test_local_headers(self, input_local_header_param_value, expected_local_header_param_value):
http_request = self.new_request_builder \
.header_param(Parameter()
- .key('header_param')
+ .key('header')
.value(input_local_header_param_value)) \
.build(self.global_configuration)
assert http_request.headers == expected_local_header_param_value
@pytest.mark.parametrize('input_global_header_param_value, expected_global_header_param_value', [
- ('my-string', {'header_param': 'my-string'}),
- (5000, {'header_param': 5000}),
- (5000.12, {'header_param': 5000.12}),
- (str(date(1998, 2, 13)), {'header_param': '1998-02-13'}),
+ ('my-string', {'global_header': 'my-string'}),
+ (5000, {'global_header': '5000'}),
+ (5000.12, {'global_header': '5000.12'}),
+ (str(date(1998, 2, 13)), {'global_header': '1998-02-13'}),
(ApiHelper.UnixDateTime.from_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': 761117415}),
+ {'global_header': '761117415'}),
(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': '{}'.format(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
+ {'global_header': '{}'.format(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': '{}'.format(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
- ([100, 200, 300, 400], {'header_param': [100, 200, 300, 400]})
+ {'global_header': '{}'.format(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
+ ([100, 200, 300, 400], {'global_header': '[100, 200, 300, 400]'}),
+ ({'key1': 'val1', 'key2': 'val2'}, {'global_header': '{"key1": "val1", "key2": "val2"}'}),
+ (Base.employee_model(), {'global_header': ApiHelper.json_serialize(Base.employee_model())})
])
def test_global_headers(self, input_global_header_param_value, expected_global_header_param_value):
http_request = self.new_request_builder \
.build(self.global_configuration
- .global_header('header_param', input_global_header_param_value))
+ .global_header('global_header', input_global_header_param_value))
assert http_request.headers == expected_global_header_param_value
@pytest.mark.parametrize('input_additional_header_param_value, expected_additional_header_param_value', [
- ('my-string', {'header_param': 'my-string'}),
- (5000, {'header_param': 5000}),
- (5000.12, {'header_param': 5000.12}),
- (str(date(1998, 2, 13)), {'header_param': '1998-02-13'}),
+ ('my-string', {'additional_header': 'my-string'}),
+ (2000, {'additional_header': '2000'}),
+ (2000.12, {'additional_header': '2000.12'}),
+ (str(date(1998, 2, 13)), {'additional_header': '1998-02-13'}),
(ApiHelper.UnixDateTime.from_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': 761117415}),
+ {'additional_header': '761117415'}),
(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': '{}'.format(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
+ {'additional_header': '{}'.format(Base.get_http_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)),
- {'header_param': '{}'.format(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
- ([100, 200, 300, 400], {'header_param': [100, 200, 300, 400]})
+ {'additional_header': '{}'.format(Base.get_rfc3339_datetime(datetime(1994, 2, 13, 5, 30, 15)))}),
+ ([100, 200, 300, 400], {'additional_header': '[100, 200, 300, 400]'}),
+ ({'alpha': 'val1', 'bravo': 'val2'}, {'additional_header': '{"alpha": "val1", "bravo": "val2"}'}),
+ (Base.employee_model(), {'additional_header': ApiHelper.json_serialize(Base.employee_model())})
])
def test_additional_headers(self, input_additional_header_param_value, expected_additional_header_param_value):
http_request = self.new_request_builder \
.build(self.global_configuration
- .additional_header('header_param', input_additional_header_param_value))
+ .additional_header('additional_header', input_additional_header_param_value))
assert http_request.headers == expected_additional_header_param_value
@pytest.mark.parametrize('input_global_header_param_value,'
@@ -510,7 +516,7 @@ def test_file_as_body_param(self, input_body_param_value, expected_body_param_va
actual_body_param_value = http_request.parameters
assert actual_body_param_value.read() == expected_body_param_value.read() \
- and http_request.headers['content-type'] == expected_content_type
+ and http_request.headers['Content-Type'] == expected_content_type
finally:
actual_body_param_value.close()
expected_body_param_value.close()
diff --git a/tests/apimatic_core/response_handler_tests/test_response_handler.py b/tests/apimatic_core/response_handler_tests/test_response_handler.py
index 183b35a..5d31a4d 100644
--- a/tests/apimatic_core/response_handler_tests/test_response_handler.py
+++ b/tests/apimatic_core/response_handler_tests/test_response_handler.py
@@ -268,8 +268,7 @@ def test_api_response_convertor(self, input_http_response, expected_response_bod
.handle(input_http_response, self.global_errors())
assert isinstance(api_response, ApiResponse) and \
ApiHelper.json_serialize(api_response.body) == expected_response_body \
- and api_response.errors == expected_error_list \
- and api_response.cursor == "Test cursor"
+ and api_response.errors == expected_error_list
@pytest.mark.parametrize('input_http_response, expected_response_body, expected_error_list', [
(Base.response(text='{"key1": "value1", "key2": "value2", "errors": ["e1", "e2"]}'),
@@ -325,4 +324,4 @@ def test_date_response_body(self, input_http_response, expected_response_body):
http_response = self.new_response_handler \
.deserializer(ApiHelper.date_deserialize) \
.handle(input_http_response, self.global_errors())
- assert http_response == expected_response_body
+ assert http_response == expected_response_body
\ No newline at end of file
diff --git a/tests/apimatic_core/security/__init__.py b/tests/apimatic_core/security/__init__.py
new file mode 100644
index 0000000..d1a3757
--- /dev/null
+++ b/tests/apimatic_core/security/__init__.py
@@ -0,0 +1,3 @@
+__all__=[
+ 'signature_verification',
+]
\ No newline at end of file
diff --git a/tests/apimatic_core/security/signature_verification/__init__.py b/tests/apimatic_core/security/signature_verification/__init__.py
new file mode 100644
index 0000000..41fd574
--- /dev/null
+++ b/tests/apimatic_core/security/signature_verification/__init__.py
@@ -0,0 +1,3 @@
+__all__=[
+ 'test_hmac_signature_verifier'
+]
\ No newline at end of file
diff --git a/tests/apimatic_core/security/signature_verification/test_hmac_signature_verifier.py b/tests/apimatic_core/security/signature_verification/test_hmac_signature_verifier.py
new file mode 100644
index 0000000..8414690
--- /dev/null
+++ b/tests/apimatic_core/security/signature_verification/test_hmac_signature_verifier.py
@@ -0,0 +1,345 @@
+import hashlib
+import hmac as _hmac
+from typing import Callable, Optional, Union, Dict, Any
+
+import pytest
+from apimatic_core_interfaces.http.request import Request
+
+from apimatic_core.security.signature_verifiers.hmac_signature_verifier import (
+ HmacSignatureVerifier,
+ HexEncoder,
+ Base64Encoder,
+ Base64UrlEncoder,
+)
+
+# ---------------------------
+# Helpers for frozen Request
+# ---------------------------
+
+def _clone_with(req: Request, **overrides: Any) -> Request:
+ """
+ Return a NEW Request with selected fields overridden.
+ Avoids in-place mutation (works with frozen dataclasses/objects).
+ """
+ def _get(name: str, default=None):
+ return getattr(req, name, default)
+
+ # Copy current fields (keep defaults if a field is missing)
+ payload: Dict[str, Any] = dict(
+ method=_get("method"),
+ path=_get("path"),
+ url=_get("url"),
+ headers=dict(_get("headers", {}) or {}),
+ query=dict(_get("query", {}) or {}),
+ cookies=dict(_get("cookies", {}) or {}),
+ raw_body=_get("raw_body", None),
+ form=dict(_get("form", {}) or {}),
+ )
+ # Apply overrides
+ payload.update(overrides)
+ return Request(**payload)
+
+def _with_header(req: Request, name: str, value: str) -> Request:
+ new_headers = dict(getattr(req, "headers", {}) or {})
+ new_headers[name] = value
+ return _clone_with(req, headers=new_headers)
+
+# ---------------------------
+# Helpers (mirror verifier semantics)
+# ---------------------------
+
+def _compute_expected_signature(
+ *,
+ secret_key: str,
+ signature_value_template: Optional[str],
+ resolver: Optional[Callable[[Request], Union[bytes, str, None]]],
+ request: Request,
+ hash_alg=hashlib.sha256,
+ encoder=HexEncoder(),
+) -> str:
+ """
+ Build expected signature string exactly as the verifier does:
+
+ - If resolver is provided: its return value is passed to hmac.new(...) as-is.
+ (bytes OK; str/None raise TypeError in hmac; verifier catches this.)
+ - If resolver is None: use request.raw_body directly.
+ - Encoded digest gets interpolated into template by replacing '{digest}'.
+ If template lacks '{digest}', itβs used as-is (constant literal).
+ """
+ if resolver is None:
+ message = getattr(request, "raw_body", None)
+ else:
+ message = resolver(request)
+
+ if isinstance(message, str):
+ # We deliberately donβt encode here to mirror the real failure path.
+ raise TypeError("Test attempted to seed using str message; this path should fail in verifier.")
+
+ digest = _hmac.new(secret_key.encode("utf-8"), message, hash_alg).digest() # may raise
+ encoded = encoder.encode(digest)
+ template = signature_value_template if signature_value_template is not None else "{digest}"
+ return template.replace("{digest}", encoded) if "{digest}" in template else template
+
+def _seed_signature_header(
+ request: Request,
+ *,
+ header_name: str,
+ secret_key: str,
+ signature_value_template: Optional[str],
+ resolver: Optional[Callable[[Request], Union[bytes, str, None]]],
+ hash_alg=hashlib.sha256,
+ encoder=HexEncoder(),
+) -> Request:
+ """
+ Return a NEW Request with the signature header set (no in-place mutation).
+ """
+ expected = _compute_expected_signature(
+ secret_key=secret_key,
+ signature_value_template=signature_value_template,
+ resolver=resolver,
+ request=request,
+ hash_alg=hash_alg,
+ encoder=encoder,
+ )
+ return _with_header(request, header_name, expected)
+
+# ---------------------------
+# Module-level resolvers
+# ---------------------------
+
+def resolver_body_bytes(request: Request) -> bytes:
+ """Return textual body encoded to UTF-8 bytes (explicit builder)."""
+ body = getattr(request, "body", None)
+ return (body or "").encode("utf-8")
+
+def resolver_bytes_prefix_header(header_name: str) -> Callable[[Request], bytes]:
+ """Return b'{method}:{header}:{body-as-bytes}' (bytes builder)."""
+ def _f(req: Request) -> bytes:
+ method = getattr(req, "method", "") or ""
+ hdrs = getattr(req, "headers", {}) or {}
+ target = ""
+ needle = header_name.lower()
+ for k, v in hdrs.items():
+ if str(k).lower() == needle:
+ target = str(v)
+ break
+ body = getattr(req, "body", None)
+ return f"{method}:{target}:".encode("utf-8") + (body or "").encode("utf-8")
+ return _f
+
+def resolver_returns_str(_req: Request) -> str:
+ """Intentionally wrong: returns str so hmac.new raises TypeError (should fail)."""
+ return "not-bytes"
+
+def resolver_returns_none(_req: Request):
+ """Intentionally returns None so hmac.new raises TypeError (should fail)."""
+ return None
+
+# ---------------------------
+# Test suite
+# ---------------------------
+
+class TestHmacSignatureVerifier:
+ # ---------- Fixtures ----------
+ @pytest.fixture
+ def req_base(self) -> Request:
+ return Request(
+ method="POST",
+ path="/events",
+ url="https://example.test/events",
+ headers={"X-Timestamp": "111", "X-Meta": "ABC", "Content-Type": "application/json"},
+ query={}, # Mapping[str, List[str]]
+ cookies={}, # Mapping[str, str]
+ raw_body=b'{"event":{"id":"evt_1"},"payload":{"checksum":"abc"}}',
+ form={}, # Mapping[str, List[str]]
+ )
+
+ @pytest.fixture
+ def enc_hex(self) -> HexEncoder:
+ return HexEncoder()
+
+ @pytest.fixture
+ def enc_b64(self) -> Base64Encoder:
+ return Base64Encoder()
+
+ @pytest.fixture
+ def enc_b64url(self) -> Base64UrlEncoder:
+ return Base64UrlEncoder()
+
+ # ---------- Constructor validation ----------
+ @pytest.mark.parametrize("secret", ["", None])
+ def test_ctor_rejects_bad_secret(self, secret):
+ with pytest.raises(ValueError):
+ HmacSignatureVerifier(
+ secret_key=secret, # type: ignore[arg-type]
+ signature_header="X-Sig",
+ )
+
+ @pytest.mark.parametrize("header", ["", " "])
+ def test_ctor_rejects_bad_header(self, header):
+ with pytest.raises(ValueError):
+ HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header=header, # type: ignore[arg-type]
+ )
+
+ # ---------- Happy paths ----------
+ @pytest.mark.parametrize(
+ "header, resolver, hash_alg, encoder, template",
+ [
+ ("X-Sig", resolver_body_bytes, hashlib.sha256, HexEncoder(), "{digest}"),
+ ("X-Wrapped", resolver_bytes_prefix_header("X-Timestamp"), hashlib.sha256, HexEncoder(), "v0={digest}"),
+ ("X-Base64", resolver_body_bytes, hashlib.sha512, Base64Encoder(), "{digest}"),
+ ("X-Base64Url", resolver_body_bytes, hashlib.sha512, Base64UrlEncoder(), "{digest}"),
+ ("X-Const", resolver_body_bytes, hashlib.sha256, HexEncoder(), "CONST"),
+ ],
+ ids=["hex_default", "hex_wrapped", "b64_sha512", "b64url_sha512", "constant_literal"],
+ )
+ def test_verify_success_variants(self, header, resolver, hash_alg, encoder, template, req_base):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header=header,
+ canonical_message_builder=resolver,
+ hash_alg=hash_alg,
+ encoder=encoder,
+ signature_value_template=template,
+ )
+ req_signed = _seed_signature_header(
+ req_base,
+ header_name=header,
+ secret_key="secret",
+ signature_value_template=template,
+ resolver=resolver,
+ hash_alg=hash_alg,
+ encoder=encoder,
+ )
+ assert verifier.verify(req_signed).ok
+
+ @pytest.mark.parametrize("cased", ["X-SIG", "x-sig", "X-Sig"])
+ def test_verify_header_lookup_case_insensitive(self, cased, enc_hex, req_base):
+ header = "X-Sig"
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header=header,
+ canonical_message_builder=resolver_body_bytes,
+ encoder=enc_hex,
+ )
+ value = _compute_expected_signature(
+ secret_key="secret",
+ signature_value_template="{digest}",
+ resolver=resolver_body_bytes,
+ request=req_base,
+ hash_alg=hashlib.sha256,
+ encoder=enc_hex,
+ )
+ req_signed = _with_header(req_base, cased, value)
+ assert verifier.verify(req_signed).ok
+
+ # ---------- Fallback behavior when builder is None ----------
+ def test_verify_uses_raw_body_when_builder_none(self, enc_hex, req_base):
+ # Build a NEW request with a different raw_body
+ req_alt = _clone_with(req_base, raw_body=b'{"event":{"id":"DIFFERENT"}}')
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Sig",
+ canonical_message_builder=None, # fallback path
+ encoder=enc_hex,
+ )
+ req_signed = _seed_signature_header(
+ req_alt,
+ header_name="X-Sig",
+ secret_key="secret",
+ signature_value_template="{digest}",
+ resolver=None, # seed matches "builder None" path
+ hash_alg=hashlib.sha256,
+ encoder=enc_hex,
+ )
+ assert verifier.verify(req_signed).ok
+
+ # ---------- Negative: header problems ----------
+ def test_missing_signature_header_fails(self, req_base, enc_hex):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Missing",
+ canonical_message_builder=resolver_body_bytes,
+ encoder=enc_hex,
+ )
+ result = verifier.verify(req_base)
+ assert not result.ok and "Signature header 'x-missing' is missing" == result.errors[0]
+
+ def test_blank_signature_header_fails(self, req_base, enc_hex):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Blank",
+ canonical_message_builder=resolver_body_bytes,
+ encoder=enc_hex,
+ )
+ req_with_blank = _with_header(req_base, "X-Blank", " ")
+ result = verifier.verify(req_with_blank)
+ assert not result.ok
+
+ # ---------- Negative: mismatch ----------
+ def test_signature_mismatch_fails(self, req_base, enc_hex):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Sig",
+ canonical_message_builder=resolver_body_bytes,
+ encoder=enc_hex,
+ )
+ req_wrong = _with_header(req_base, "X-Sig", "wrong")
+ result = verifier.verify(req_wrong)
+ assert not result.ok and "Signature mismatch" in str(result.errors[0])
+
+ # ---------- Negative: resolver returns wrong type / None ----------
+ @pytest.mark.parametrize("bad_resolver, error_message", [
+ (resolver_returns_str, "Signature Verification Failed"),
+ (resolver_returns_none, "Signature mismatch"),
+ ])
+ def test_resolver_returning_invalid_leads_to_failed_result(self, bad_resolver, error_message, req_base):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Sig",
+ canonical_message_builder=bad_resolver,
+ )
+ req_seeded = _with_header(req_base, "X-Sig", "does-not-matter")
+ result = verifier.verify(req_seeded)
+ assert not result.ok and error_message in str(result.errors[0])
+
+ # ---------- Negative: encoder misconfigured (None) ----------
+ def test_encoder_none_causes_failed_result(self, req_base):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Sig",
+ canonical_message_builder=resolver_body_bytes,
+ encoder=None, # will cause AttributeError when .encode is called
+ )
+ req_seeded = _with_header(req_base, "X-Sig", "whatever")
+ result = verifier.verify(req_seeded)
+ assert not result.ok and "Signature Verification Failed" in str(result.errors[0])
+
+ # ---------- Negative: fallback path with builder=None and raw_body=None ----------
+ def test_builder_none_and_no_raw_body_causes_failed_result(self, req_base):
+ req = _clone_with(req_base, headers={"X-Sig": "whatever"}, raw_body=None)
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Sig",
+ canonical_message_builder=None,
+ )
+ result = verifier.verify(req)
+ assert not result.ok and "Signature mismatch" in str(result.errors[0])
+
+ # ---------- Negative: custom hash that raises ----------
+ class BoomHash:
+ def __call__(self, *args, **kwargs):
+ raise RuntimeError("boom")
+
+ def test_hash_function_raises_produces_failed_result(self, req_base):
+ verifier = HmacSignatureVerifier(
+ secret_key="secret",
+ signature_header="X-Sig",
+ canonical_message_builder=resolver_body_bytes,
+ hash_alg=self.BoomHash(), # invoking hmac.new will trigger our error
+ )
+ req_seeded = _with_header(req_base, "X-Sig", "anything")
+ result = verifier.verify(req_seeded)
+ assert not result.ok and "Signature Verification Failed" in str(result.errors[0])
diff --git a/tests/apimatic_core/utility_tests/test_api_helper.py b/tests/apimatic_core/utility_tests/test_api_helper.py
index e787022..1886139 100644
--- a/tests/apimatic_core/utility_tests/test_api_helper.py
+++ b/tests/apimatic_core/utility_tests/test_api_helper.py
@@ -1,3 +1,4 @@
+import copy
from datetime import datetime, date
import jsonpickle
@@ -1099,4 +1100,86 @@ def test_apply_unboxing_function(self, value, unboxing_function, is_array, is_di
is_array_of_map,
is_map_of_array,
dimension_count)
- assert result == expected
\ No newline at end of file
+ assert result == expected
+
+ @pytest.mark.parametrize(
+ "dictionary, pointer, expected",
+ [
+ ({"foo": "bar"}, "/foo", "bar"), # basic access
+ ({"a": {"b": {"c": 1}}}, "/a/b/c", 1), # nested path
+ ({"list": [10, 20, 30]}, "/list/1", 20), # list index
+ ({}, "/missing", None), # missing key
+ ({"x": {"y": 5}}, "/x/z", None), # partial match but invalid final key
+ ({"": "empty_key"}, "/", "empty_key"), # root-level empty string key
+ ]
+ )
+ def test_get_value_by_json_pointer_valid_and_invalid(self, dictionary, pointer, expected):
+ result = ApiHelper.get_value_by_json_pointer(dictionary, pointer)
+ assert result == expected
+
+ def test_get_value_by_json_pointer_raises_invalid_pointer(self):
+ # Pointer with invalid format (should raise and be caught internally)
+ result = ApiHelper.get_value_by_json_pointer({"foo": "bar"}, "invalid_pointer")
+ assert result is None
+
+ @pytest.mark.parametrize(
+ "pointer, json_body, json_headers, expected",
+ [
+ ("$response.body#/name", '{"name": "Alice"}', {}, "Alice"),
+ ("$response.body#/details/age", '{"details": {"age": 30}}', {}, 30),
+ ("$response.headers#/X-Request-ID", "", {"X-Request-ID": "abc-123"}, "abc-123"),
+ ("$response.body#/missing", '{"name": "Alice"}', {}, None),
+ ("$response.headers#/missing", "", {"X-Request-ID": "abc-123"}, None),
+ ("$response.unknown#/path", '{"some": "data"}', {}, None),
+ ("", '{"some": "data"}', {}, None),
+ (None, '{"some": "data"}', {}, None),
+ ]
+ )
+ def test_resolve_response_pointer(self, pointer, json_body, json_headers, expected):
+ result = ApiHelper.resolve_response_pointer(pointer, json_body, json_headers)
+ assert result == expected
+
+ @pytest.mark.parametrize(
+ "json_pointer, expected",
+ [
+ ("$response.body#/name", ("$response.body", "/name")),
+ ("$response.headers#/X-Header", ("$response.headers", "/X-Header")),
+ ("$response.body#", ("$response.body", "")),
+ ("$response.body", ("$response.body", "")),
+ ("", (None, None)),
+ (None, (None, None)),
+ ]
+ )
+ def test_split_into_parts(self, json_pointer, expected):
+ result = ApiHelper.split_into_parts(json_pointer)
+ assert result == expected
+
+ @pytest.mark.parametrize(
+ "initial_dict, pointer, new_value, inplace, expected_dict",
+ [
+ ({"name": "Alice"}, "/name", "Bob", True, {"name": "Bob"}),
+ ({"a": {"b": 1}}, "/a/b", 2, True, {"a": {"b": 2}}),
+ ({}, "/new/key", "value", True, {"new": {"key": "value"}}),
+ ({"x": 1}, "/x", {"nested": "yes"}, False, {"x": {"nested": "yes"}}),
+ ]
+ )
+ def test_update_entry_by_json_pointer(self, initial_dict, pointer, new_value, inplace, expected_dict):
+ original_copy = copy.deepcopy(initial_dict)
+ result = ApiHelper.update_entry_by_json_pointer(initial_dict, pointer, new_value, inplace=inplace)
+
+ assert result == expected_dict
+ if not inplace:
+ assert initial_dict == original_copy
+
+ @pytest.mark.parametrize("url, expected", [
+ (None, {}),
+ ("https://example.com/path?name=Sufyan", {"name": "Sufyan"}),
+ ("https://example.com/api?name=John&role=Engineer", {"name": "John", "role": "Engineer"}),
+ ("https://example.com/home", {}),
+ ("https://example.com/search?tag=python&tag=testing", {"tag": "testing"}), # last one wins
+ ("https://example.com/?name=John%20Doe&role=Senior%20Engineer",
+ {"name": "John Doe", "role": "Senior Engineer"}),
+ ("https://example.com/?debug=&verbose=true", {"verbose": "true"}),
+ ])
+ def test_get_query_parameters(self, url, expected):
+ assert ApiHelper.get_query_parameters(url) == expected