diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ab5c89 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# BerAPI Configuration +# Copy this file to .env and modify as needed + +# Base configuration +BERAPI_BASE_URL=https://api.example.com +BERAPI_TIMEOUT=30.0 +BERAPI_MAX_RESPONSE_TIME=10.0 +BERAPI_VERIFY_SSL=true + +# Logging configuration +BERAPI_LOG_LEVEL=INFO +BERAPI_LOG_FORMAT=json +BERAPI_LOG_REQUEST_BODY=true +BERAPI_LOG_RESPONSE_BODY=true +BERAPI_LOG_HEADERS=true +BERAPI_LOG_CURL=true + +# Retry configuration +BERAPI_RETRY_ENABLED=true +BERAPI_MAX_RETRIES=3 +BERAPI_BACKOFF_FACTOR=0.5 +BERAPI_BACKOFF_MAX=60.0 +BERAPI_RETRY_JITTER=true + +# OpenAPI validation (optional) +# BERAPI_OPENAPI_SPEC=/path/to/openapi.yaml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..22b12cf --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,105 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + inputs: + test_pypi: + description: 'Publish to Test PyPI instead' + required: false + default: false + type: boolean + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Run tests + run: poetry run pytest tests/ -v --tb=short + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + + - name: Build package + run: poetry build + + - name: Store distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-pypi: + name: Publish to PyPI + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.test_pypi) + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/berapi + permissions: + id-token: write # Required for trusted publishing + + steps: + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + publish-test-pypi: + name: Publish to Test PyPI + if: github.event_name == 'workflow_dispatch' && inputs.test_pypi + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/berapi + permissions: + id-token: write # Required for trusted publishing + + steps: + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..66158dc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install project + run: poetry install --no-interaction + + - name: Run tests + run: poetry run pytest tests/ -v --tb=short + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.python-version }} + path: reports/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index fa5dbfe..6f50570 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ reports/ builds/ dist/ **/*.html -**/.DS_Store \ No newline at end of file +**/.DS_Store +.claude/ \ No newline at end of file diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..f459b87 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,321 @@ +# Migration Guide: v1 to v2 + +This guide helps you migrate from berAPI v1 to v2. Version 2 is a complete redesign with breaking changes. + +## Overview of Changes + +- New package structure with `src/` layout +- New `Settings` class for configuration +- Middleware system for extensibility +- Structured logging with `structlog` +- Built-in retry with exponential backoff +- Renamed assertion methods for clarity +- Unified data access with `get()` method + +## Import Changes + +### v1 +```python +from berapi.apy import berAPI +``` + +### v2 +```python +from berapi import BerAPI, Settings +``` + +## Client Initialization + +### v1 +```python +api = berAPI( + base_url='https://api.example.com', + base_headers={'Authorization': 'Bearer token'} +) +``` + +### v2 +```python +from berapi import BerAPI, Settings +from berapi.middleware import BearerAuthMiddleware + +api = BerAPI( + Settings(base_url='https://api.example.com'), + middlewares=[BearerAuthMiddleware(token='token')] +) + +# Or with headers directly +api = BerAPI(Settings( + base_url='https://api.example.com', + headers={'Authorization': 'Bearer token'} +)) +``` + +## Environment Variables + +### v1 +```bash +MAX_TIMEOUT=3 +MAX_RESPONSE_TIME=5 +``` + +### v2 +```bash +BERAPI_TIMEOUT=30 +BERAPI_MAX_RESPONSE_TIME=10 +BERAPI_LOG_LEVEL=INFO +BERAPI_RETRY_ENABLED=true +BERAPI_MAX_RETRIES=3 +``` + +## Assertion Methods + +### Status Code Assertions (unchanged) + +```python +# v1 and v2 - same API +response.assert_2xx() +response.assert_4xx() +response.assert_status_code(200) # v1 +response.assert_status(200) # v2 (renamed) +``` + +### JSON Value Assertions + +#### v1 +```python +response.assert_value('name', 'John') +response.assert_value('user.email', 'john@example.com') +``` + +#### v2 +```python +response.assert_json_path('name', 'John') +response.assert_json_path('user.email', 'john@example.com') +``` + +### Schema Validation + +#### v1 +```python +response.assert_schema('schemas/user.json') +response.assert_schema_from_sample('samples/user.json') +``` + +#### v2 +```python +response.assert_json_schema('schemas/user.json') +response.assert_json_schema_from_sample('samples/user.json') +``` + +### Other Assertion Changes + +| v1 | v2 | +|----|-----| +| `assert_value(key, val)` | `assert_json_path(key, val)` | +| `assert_value_not_empty(key)` | `assert_json_not_empty(key)` | +| `assert_has_key(key)` | `assert_has_key(key)` (same) | +| `assert_value_in(key, list)` | `assert_json_in(key, list)` | +| `assert_response_time_less_than(sec)` | `assert_response_time(sec)` | +| `assert_status_code(code)` | `assert_status(code)` | + +## Data Access Methods + +### v1 +```python +# Different methods for different access patterns +value = response.get_property('name') # Root property +nested = response.get_value('user.email') # Nested with dot notation +data = response.get_data('id') # From 'data' wrapper +json_data = response.parse_json() # Parse JSON +``` + +### v2 +```python +# Unified access with get() +value = response.get('name') # Root property +nested = response.get('user.email') # Nested with dot notation +data = response.get('data.id') # From any path +json_data = response.to_dict() # Get as dict +``` + +## Logging + +### v1 +Standard Python logging with `logging` module. + +### v2 +Structured logging with `structlog`. Configure via settings: + +```python +from berapi import BerAPI, Settings, LoggingSettings + +api = BerAPI(Settings( + logging=LoggingSettings( + level='DEBUG', + format='console', # or 'json' + log_curl=True, + ) +)) +``` + +## Middleware (New in v2) + +v1 had no middleware system. v2 introduces middleware for extensibility: + +```python +from berapi import BerAPI, Settings +from berapi.middleware import ( + LoggingMiddleware, + BearerAuthMiddleware, +) + +api = BerAPI( + Settings(base_url='https://api.example.com'), + middlewares=[ + LoggingMiddleware(), + BearerAuthMiddleware(token='your-token'), + ] +) +``` + +### Custom Middleware + +```python +from berapi.middleware import RequestContext, ResponseContext + +class CustomMiddleware: + def process_request(self, context: RequestContext) -> RequestContext: + # Modify request + return context.with_header('X-Custom', 'value') + + def process_response(self, context: ResponseContext) -> ResponseContext: + # Process response + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + # Handle errors + pass +``` + +## Retry (New in v2) + +v1 had no retry support. v2 has built-in retry with exponential backoff: + +```python +from berapi import BerAPI, Settings, RetrySettings + +api = BerAPI(Settings( + retry=RetrySettings( + enabled=True, + max_retries=3, + backoff_factor=0.5, + retry_statuses=frozenset({429, 500, 502, 503, 504}), + ) +)) +``` + +## Error Handling + +### v1 +Generic exceptions and `requests.Timeout`. + +### v2 +Custom exception hierarchy with detailed information: + +```python +from berapi.exceptions import ( + StatusCodeError, + JsonPathError, + TimeoutError, + RetryExhaustedError, + JsonSchemaError, +) + +try: + response = api.get('/users/1').assert_2xx() +except StatusCodeError as e: + print(f"Expected {e.expected}, got {e.actual}") +except JsonPathError as e: + print(f"Path {e.path}: expected {e.expected}, got {e.actual}") +``` + +## Complete Migration Example + +### v1 Test +```python +from berapi.apy import berAPI + +def test_user_crud(): + api = berAPI( + base_url='https://api.example.com', + base_headers={'Authorization': 'Bearer token'} + ) + + # Create + response = api.post('/users', json={'name': 'John'}) + response.assert_status_code(201) + user_id = response.get_value('data.id') + + # Read + response = api.get(f'/users/{user_id}') + response.assert_2xx().assert_value('data.name', 'John') + + # Update + response = api.put(f'/users/{user_id}', json={'name': 'Jane'}) + response.assert_2xx() + + # Delete + api.delete(f'/users/{user_id}').assert_2xx() +``` + +### v2 Test +```python +from berapi import BerAPI, Settings +from berapi.middleware import LoggingMiddleware, BearerAuthMiddleware + +def test_user_crud(): + api = BerAPI( + Settings(base_url='https://api.example.com'), + middlewares=[ + LoggingMiddleware(), + BearerAuthMiddleware(token='token'), + ] + ) + + # Create + response = api.post('/users', json={'name': 'John'}) + response.assert_status(201) + user_id = response.get('data.id') + + # Read + response = api.get(f'/users/{user_id}') + response.assert_2xx().assert_json_path('data.name', 'John') + + # Update + response = api.put(f'/users/{user_id}', json={'name': 'Jane'}) + response.assert_2xx() + + # Delete + api.delete(f'/users/{user_id}').assert_2xx() +``` + +## Quick Reference + +| v1 | v2 | +|----|-----| +| `from berapi.apy import berAPI` | `from berapi import BerAPI, Settings` | +| `berAPI(base_url=..., base_headers=...)` | `BerAPI(Settings(base_url=..., headers=...))` | +| `response.get_value('a.b')` | `response.get('a.b')` | +| `response.get_property('key')` | `response.get('key')` | +| `response.get_data('key')` | `response.get('data.key')` | +| `response.parse_json()` | `response.to_dict()` | +| `response.assert_value(key, val)` | `response.assert_json_path(key, val)` | +| `response.assert_value_not_empty(key)` | `response.assert_json_not_empty(key)` | +| `response.assert_value_in(key, list)` | `response.assert_json_in(key, list)` | +| `response.assert_schema(file)` | `response.assert_json_schema(file)` | +| `response.assert_schema_from_sample(file)` | `response.assert_json_schema_from_sample(file)` | +| `response.assert_status_code(code)` | `response.assert_status(code)` | +| `response.assert_response_time_less_than(sec)` | `response.assert_response_time(sec)` | +| `MAX_TIMEOUT` env | `BERAPI_TIMEOUT` env | +| `MAX_RESPONSE_TIME` env | `BERAPI_MAX_RESPONSE_TIME` env | diff --git a/README.md b/README.md index 3f70dd8..7dec83b 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,1022 @@ -# 🔥 berAPI 🔥 -berapi is a lightweight API client that simplifies API testing using Python and Pytest -It supports chained assertions, curl logging, and quick response validation for fast and efficient API testing +# BerAPI -[Project Link](https://pypi.org/project/berapi/) +A modern, scalable API testing library for Python with middleware support, structured logging, and fluent assertions. -## Features -- Builtin curl API in the `pytest-html` report -- Easy to import the API logs into Postman/curl -- Multiple common assertions to a single request -- -## ✨ Features -- 🔥 Simple Fluent API — chainable syntax like .get().assert_2xx().parse_json() -- 📋 Auto Logging — automatically generate curl commands for debugging or Postman import -- 🛡️ Built-in Assertions — status code, response body, JSON key/value, and schema validation -- 🕐 Response Time Checking — ensure your APIs are fast and stable -- 📜 JSONPath Support (coming soon) — flexible access to nested JSON data +[![PyPI version](https://badge.fury.io/py/berapi.svg)](https://pypi.org/project/berapi/) +[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +## Features -![Report](berapi-report.gif) +- **Fluent Assertions** - Chainable syntax like `.get().assert_2xx().assert_json_path("name", "John")` +- **Middleware System** - Extensible request/response middleware for logging, auth, and custom logic +- **Structured Logging** - JSON-formatted logs with structlog for easy parsing and debugging +- **Retry with Backoff** - Automatic retries with exponential backoff and jitter +- **OpenAPI Validation** - Validate responses against OpenAPI/Swagger specifications +- **JSON Schema Validation** - Validate responses against JSON Schema +- **Type Hints** - Full type annotations for IDE support and type checking ## Installation + ```bash -pip3 install berapi +pip install berapi ``` -## How to use -Create an instance of berAPI class, and you can build API test request and assertion chain of the response +## Quick Start ```python -from berapi.apy import berAPI +from berapi import BerAPI, Settings +from berapi.middleware import LoggingMiddleware -def test_simple(): - url = 'https://swapi.dev/api/people/1' - api = berAPI() - response = api.get(url).assert_2xx().parse_json() - assert response['name'] == 'Luke Skywalker' +# Create client with configuration +api = BerAPI( + Settings(base_url="https://jsonplaceholder.typicode.com"), + middlewares=[LoggingMiddleware()] +) -def test_chaining(): - (berAPI() - .get('https://swapi.dev/api/people/1') - .assert_2xx() - .assert_value('name', 'Luke Skywalker') - .assert_response_time_less_than(seconds=1) - ) +# Make request with fluent assertions +response = ( + api.get("/posts/1") + .assert_2xx() + .assert_json_path("userId", 1) + .assert_response_time(2.0) +) + +# Access response data +post = response.to_dict() +title = response.get("title") ``` -### Configuration -env variable used in berapi -```bash -export MAX_RESPONSE_TIME=5 -export MAX_TIMEOUT=3 +## Table of Contents + +- [Configuration](#configuration) +- [Making Requests](#making-requests) +- [Assertions](#assertions) +- [Data Access](#data-access) +- [Middleware](#middleware) + - [Why Use Middleware?](#why-use-middleware) + - [Built-in Middleware](#built-in-middleware) + - [Custom Middleware Examples](#custom-middleware-examples) +- [Retry and Backoff](#retry-and-backoff) + - [Why Use Retry?](#why-use-retry) + - [How Exponential Backoff Works](#how-exponential-backoff-works) + - [Use Cases](#use-cases) +- [OpenAPI Validation](#openapi-validation) + - [Why Use OpenAPI Validation?](#why-use-openapi-validation) + - [Setup](#setup) + - [Use Cases](#use-cases-1) +- [Error Handling](#error-handling) +- [Migration from v1](#migration-from-v1) + +## Configuration + +### Using Settings + +```python +from berapi import BerAPI, Settings, LoggingSettings, RetrySettings + +api = BerAPI(Settings( + base_url="https://api.example.com", + timeout=30.0, + max_response_time=10.0, # Fail if response takes longer + verify_ssl=True, + headers={"X-Custom-Header": "value"}, + logging=LoggingSettings( + level="INFO", + format="json", # or "console" + log_curl=True, + ), + retry=RetrySettings( + enabled=True, + max_retries=3, + backoff_factor=0.5, + jitter=True, + ), +)) +``` + +### Using Environment Variables + +```python +from berapi import BerAPI, Settings + +# Load all settings from environment +api = BerAPI(Settings.from_env()) +``` + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `BERAPI_BASE_URL` | None | Base URL for requests | +| `BERAPI_TIMEOUT` | 30.0 | Request timeout (seconds) | +| `BERAPI_MAX_RESPONSE_TIME` | None | Max response time threshold | +| `BERAPI_VERIFY_SSL` | true | Verify SSL certificates | +| `BERAPI_LOG_LEVEL` | INFO | Log level (DEBUG, INFO, WARNING, ERROR) | +| `BERAPI_LOG_FORMAT` | json | Log format (json, console) | +| `BERAPI_LOG_CURL` | true | Log curl commands | +| `BERAPI_RETRY_ENABLED` | true | Enable retry | +| `BERAPI_MAX_RETRIES` | 3 | Max retry attempts | +| `BERAPI_BACKOFF_FACTOR` | 0.5 | Backoff multiplier | +| `BERAPI_OPENAPI_SPEC` | None | Path to OpenAPI spec | + +## Making Requests + +### HTTP Methods + +```python +from berapi import BerAPI, Settings + +api = BerAPI(Settings(base_url="https://api.example.com")) + +# GET +response = api.get("/users", params={"page": 1}) + +# POST with JSON +response = api.post("/users", json={"name": "John", "email": "john@example.com"}) + +# PUT +response = api.put("/users/1", json={"name": "Jane"}) + +# PATCH +response = api.patch("/users/1", json={"email": "jane@example.com"}) + +# DELETE +response = api.delete("/users/1") + +# Custom method +response = api.request("OPTIONS", "/users") +``` + +### Request Options + +```python +# Custom headers +response = api.get("/users", headers={"X-Request-ID": "123"}) + +# Query parameters +response = api.get("/users", params={"page": 1, "limit": 10}) + +# Custom timeout +response = api.get("/users", timeout=60.0) +``` + +## Assertions + +All assertion methods return `self` for chaining. + +### Status Code + +```python +response.assert_status(200) # Exact status +response.assert_status_range(200, 299) # Range +response.assert_2xx() # 200-299 +response.assert_3xx() # 300-399 +response.assert_4xx() # 400-499 +response.assert_5xx() # 500-599 ``` +### Headers -To have robust response log make sure you enable settings in pytest.ini -```ini -[pytest] -log_cli_level = INFO +```python +response.assert_header("X-Request-ID", "123") +response.assert_header_exists("X-Rate-Limit") +response.assert_content_type("application/json") +``` + +### Response Body + +```python +response.assert_contains("success") +response.assert_not_contains("error") ``` -### Install Development +### JSON + +```python +# Assert value at path (supports dot notation) +response.assert_json_path("name", "John") +response.assert_json_path("user.email", "john@example.com") +response.assert_json_path("items.0.id", 1) + +# Assert key exists +response.assert_has_key("id") +response.assert_has_key("user.profile.avatar") + +# Assert not empty +response.assert_json_not_empty("name") + +# Assert value in list +response.assert_json_in("status", ["active", "pending", "inactive"]) + +# Assert list response +response.assert_list_not_empty() +``` + +### Schema Validation + +```python +# JSON Schema from dict +response.assert_json_schema({ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"} + } +}) + +# JSON Schema from file +response.assert_json_schema("schemas/user.json") + +# Auto-generated schema from sample +response.assert_json_schema_from_sample("samples/user_response.json") +``` + +### OpenAPI Validation + +```python +# With spec path +response.assert_openapi("getUser", spec_path="openapi.yaml") + +# With configured spec +api = BerAPI(Settings(openapi_spec_path="openapi.yaml")) +api.get("/users/1").assert_openapi("getUser") +``` + +### Performance + +```python +response.assert_response_time(2.0) # Max 2 seconds +``` + +## Data Access + +```python +# Get entire response as dict +data = response.to_dict() + +# Get value with dot notation +user_id = response.get("id") +email = response.get("user.email") +first_item = response.get("items.0") + +# Get with default +status = response.get("status", "unknown") + +# Get multiple values +values = response.get_all(["id", "name", "email"]) + +# Access properties +status_code = response.status_code +headers = response.headers +text = response.text +elapsed = response.elapsed +``` + +## Middleware + +Middleware provides a powerful way to intercept and modify requests and responses. It follows the chain of responsibility pattern, allowing you to compose multiple middleware for different concerns. + +### Why Use Middleware? + +- **Separation of Concerns** - Keep authentication, logging, and other cross-cutting concerns separate from your test logic +- **Reusability** - Write once, use across all your API tests +- **Composability** - Stack multiple middleware to build complex behaviors +- **Testability** - Easy to mock and test individual middleware components + +### How Middleware Works + +``` +Request Flow: Client -> Middleware1 -> Middleware2 -> Server +Response Flow: Client <- Middleware1 <- Middleware2 <- Server +``` + +Each middleware can: +1. **Modify requests** before they're sent (add headers, transform body, etc.) +2. **Modify responses** after they're received (parse, validate, transform) +3. **Handle errors** that occur during the request/response cycle + +### Built-in Middleware + +#### LoggingMiddleware + +Provides structured logging for all HTTP requests and responses. + +```python +from berapi import BerAPI, Settings +from berapi.middleware import LoggingMiddleware + +api = BerAPI( + Settings(base_url="https://api.example.com"), + middlewares=[ + LoggingMiddleware( + log_curl=True, # Log curl command for reproduction + log_request_body=True, # Log request body + log_response_body=True, # Log response body + log_headers=True, # Log headers + max_body_length=10000, # Truncate large bodies + redact_headers=frozenset({ # Hide sensitive headers + "authorization", + "x-api-key", + "cookie" + }), + ) + ] +) +``` + +**Output Example (JSON format):** +```json +{ + "event": "http_request", + "method": "POST", + "url": "https://api.example.com/users", + "headers": {"Authorization": "[REDACTED]", "Content-Type": "application/json"}, + "body": {"name": "John", "email": "john@example.com"}, + "curl": "curl -X POST 'https://api.example.com/users' -H 'Content-Type: application/json' -d '{\"name\":\"John\"}'", + "timestamp": "2024-01-15T10:30:00Z" +} +``` + +#### BearerAuthMiddleware + +Automatically adds Bearer token authentication to all requests. + +```python +from berapi.middleware import BearerAuthMiddleware + +# Static token +api = BerAPI( + Settings(base_url="https://api.example.com"), + middlewares=[BearerAuthMiddleware(token="your-jwt-token")] +) + +# Dynamic token (refreshable) +def get_fresh_token(): + # Fetch from token service, cache, or generate new + return token_service.get_access_token() + +api = BerAPI( + Settings(base_url="https://api.example.com"), + middlewares=[BearerAuthMiddleware(token=get_fresh_token)] +) +``` + +#### ApiKeyMiddleware + +Adds API key authentication via custom header. + +```python +from berapi.middleware import ApiKeyMiddleware + +# Default header (X-API-Key) +api = BerAPI( + middlewares=[ApiKeyMiddleware(api_key="your-api-key")] +) + +# Custom header name +api = BerAPI( + middlewares=[ApiKeyMiddleware( + api_key="your-api-key", + header_name="X-Custom-Auth", + prefix="ApiKey " # Optional prefix + )] +) +``` + +### Custom Middleware Examples + +#### Request ID Middleware + +Add unique request IDs for tracing: + +```python +import uuid +from berapi.middleware import RequestContext, ResponseContext + +class RequestIdMiddleware: + def process_request(self, context: RequestContext) -> RequestContext: + request_id = str(uuid.uuid4()) + return context.with_header("X-Request-ID", request_id) + + def process_response(self, context: ResponseContext) -> ResponseContext: + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + pass +``` + +#### Timing Middleware + +Track and alert on slow requests: + +```python +import time +from berapi.middleware import RequestContext, ResponseContext + +class TimingMiddleware: + def __init__(self, warn_threshold: float = 1.0): + self.warn_threshold = warn_threshold + + def process_request(self, context: RequestContext) -> RequestContext: + # Store start time in metadata + return context.with_metadata("start_time", time.time()) + + def process_response(self, context: ResponseContext) -> ResponseContext: + start_time = context.request_context.metadata.get("start_time") + if start_time: + elapsed = time.time() - start_time + if elapsed > self.warn_threshold: + print(f"SLOW REQUEST: {context.request_context.url} took {elapsed:.2f}s") + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + pass +``` + +#### Response Caching Middleware + +Cache responses for repeated requests: + +```python +import hashlib +import json +from berapi.middleware import RequestContext, ResponseContext + +class CachingMiddleware: + def __init__(self): + self._cache = {} + + def _cache_key(self, context: RequestContext) -> str: + key_data = f"{context.method}:{context.url}:{json.dumps(context.params or {})}" + return hashlib.md5(key_data.encode()).hexdigest() + + def process_request(self, context: RequestContext) -> RequestContext: + # Only cache GET requests + if context.method == "GET": + cache_key = self._cache_key(context) + context = context.with_metadata("cache_key", cache_key) + return context + + def process_response(self, context: ResponseContext) -> ResponseContext: + cache_key = context.request_context.metadata.get("cache_key") + if cache_key and context.status_code == 200: + self._cache[cache_key] = context.response.json() + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + pass +``` + +#### Error Notification Middleware + +Send alerts on failures: + +```python +class SlackNotificationMiddleware: + def __init__(self, webhook_url: str, notify_on_status: list[int] = None): + self.webhook_url = webhook_url + self.notify_on_status = notify_on_status or [500, 502, 503, 504] + + def process_request(self, context: RequestContext) -> RequestContext: + return context + + def process_response(self, context: ResponseContext) -> ResponseContext: + if context.status_code in self.notify_on_status: + self._send_notification( + f"API Error: {context.request_context.method} {context.request_context.url} " + f"returned {context.status_code}" + ) + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + self._send_notification(f"API Exception: {context.url} - {error}") + + def _send_notification(self, message: str): + import requests + requests.post(self.webhook_url, json={"text": message}) +``` + +### Middleware Order + +Middleware executes in order for requests and reverse order for responses: + +```python +api = BerAPI( + middlewares=[ + LoggingMiddleware(), # 1st for request, 3rd for response + BearerAuthMiddleware(), # 2nd for request, 2nd for response + TimingMiddleware(), # 3rd for request, 1st for response + ] +) +``` + +### Adding Middleware Dynamically + +```python +api = BerAPI(Settings(base_url="https://api.example.com")) + +# Add middleware after creation +api.add_middleware(LoggingMiddleware()) +api.add_middleware(BearerAuthMiddleware(token="token")) + +# Middleware is added to the end of the chain +``` + +--- + +## Retry and Backoff + +BerAPI includes built-in retry functionality with exponential backoff to handle transient failures gracefully. + +### Why Use Retry? + +- **Handle Transient Failures** - Network glitches, temporary server issues +- **Rate Limiting** - Automatically retry after rate limit responses (429) +- **Improved Reliability** - Tests don't fail due to temporary issues +- **Server Recovery** - Wait for overwhelmed servers to recover + +### How Exponential Backoff Works + +Exponential backoff increases the delay between retries exponentially: + +``` +Attempt 1: Immediate +Attempt 2: Wait 0.5s (backoff_factor * 2^0) +Attempt 3: Wait 1.0s (backoff_factor * 2^1) +Attempt 4: Wait 2.0s (backoff_factor * 2^2) +... +``` + +With **jitter** (randomness), delays are varied to prevent thundering herd: +``` +Attempt 2: Wait 0.25s - 0.75s (50% - 150% of calculated delay) +``` + +### Configuration + +```python +from berapi import BerAPI, Settings, RetrySettings + +api = BerAPI(Settings( + base_url="https://api.example.com", + retry=RetrySettings( + enabled=True, # Enable/disable retry + max_retries=3, # Maximum retry attempts + backoff_factor=0.5, # Base delay multiplier + backoff_max=60.0, # Maximum delay cap (seconds) + jitter=True, # Add randomness to delays + retry_statuses=frozenset({ # Status codes to retry + 429, # Too Many Requests + 500, # Internal Server Error + 502, # Bad Gateway + 503, # Service Unavailable + 504, # Gateway Timeout + }), + ), +)) +``` + +### Use Cases + +#### Rate Limiting (429 Too Many Requests) + +```python +# API returns 429 when rate limited +# BerAPI automatically waits and retries + +api = BerAPI(Settings( + base_url="https://api.example.com", + retry=RetrySettings( + enabled=True, + max_retries=5, + backoff_factor=1.0, # Start with 1 second delay + retry_statuses=frozenset({429}), + ), +)) + +# This will retry up to 5 times if rate limited +response = api.get("/high-traffic-endpoint").assert_2xx() +``` + +#### Flaky Services + +```python +# Handle unreliable third-party services +api = BerAPI(Settings( + base_url="https://flaky-service.example.com", + retry=RetrySettings( + enabled=True, + max_retries=3, + backoff_factor=0.5, + retry_statuses=frozenset({500, 502, 503, 504}), + ), +)) +``` + +#### Load Testing Resilience + +```python +# During load tests, services may temporarily fail +api = BerAPI(Settings( + retry=RetrySettings( + enabled=True, + max_retries=2, + backoff_factor=0.25, # Quick retries + jitter=True, # Prevent synchronized retries + ), +)) +``` + +### Handling Retry Exhaustion + +```python +from berapi.exceptions import RetryExhaustedError + +api = BerAPI(Settings( + retry=RetrySettings(enabled=True, max_retries=3), +)) + +try: + response = api.get("/unreliable-endpoint").assert_2xx() +except RetryExhaustedError as e: + print(f"Failed after {e.attempts} attempts") + print(f"Last error: {e.last_error}") + # Handle permanent failure +``` + +### Disabling Retry for Specific Tests + +```python +# Global retry enabled +api = BerAPI(Settings( + retry=RetrySettings(enabled=True, max_retries=3), +)) + +# Disable for specific test by creating new client +api_no_retry = api.with_settings(retry={"enabled": False}) +response = api_no_retry.get("/endpoint-that-should-not-retry") +``` + +### Retry Timing Examples + +With `backoff_factor=0.5` and `max_retries=4`: + +| Attempt | Delay (no jitter) | Delay (with jitter) | +|---------|-------------------|---------------------| +| 1 | 0s (immediate) | 0s | +| 2 | 0.5s | 0.25s - 0.75s | +| 3 | 1.0s | 0.5s - 1.5s | +| 4 | 2.0s | 1.0s - 3.0s | +| 5 | 4.0s | 2.0s - 6.0s | + +--- + +## OpenAPI Validation + +Validate your API responses against OpenAPI (Swagger) specifications to ensure contract compliance. + +### Why Use OpenAPI Validation? + +- **Contract Testing** - Ensure API responses match documented specification +- **Regression Detection** - Catch breaking changes early +- **Documentation Accuracy** - Verify docs match implementation +- **Type Safety** - Validate response data types automatically +- **Schema Evolution** - Detect unintended schema changes + +### Setup + +#### 1. Provide OpenAPI Spec + +Create or use your existing OpenAPI specification (YAML or JSON): + +```yaml +# openapi.yaml +openapi: 3.0.0 +info: + title: User API + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: User found + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found + +components: + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + createdAt: + type: string + format: date-time +``` + +#### 2. Configure BerAPI + +```python +from berapi import BerAPI, Settings + +# Option 1: Configure in Settings +api = BerAPI(Settings( + base_url="https://api.example.com", + openapi_spec_path="openapi.yaml", +)) + +# Option 2: Specify per assertion +api = BerAPI(Settings(base_url="https://api.example.com")) +response.assert_openapi("getUser", spec_path="openapi.yaml") +``` + +### Basic Usage + +```python +from berapi import BerAPI, Settings + +api = BerAPI(Settings( + base_url="https://api.example.com", + openapi_spec_path="specs/openapi.yaml", +)) + +# Validate response matches OpenAPI spec for "getUser" operation +response = ( + api.get("/users/1") + .assert_2xx() + .assert_openapi("getUser") # Validates against spec +) +``` + +### Use Cases + +#### Contract Testing + +Ensure your API implementation matches the documented contract: + +```python +import pytest +from berapi import BerAPI, Settings + +@pytest.fixture +def api(): + return BerAPI(Settings( + base_url="https://api.example.com", + openapi_spec_path="openapi.yaml", + )) + +class TestUserAPIContract: + def test_get_user_matches_spec(self, api): + """Verify GET /users/{id} matches OpenAPI spec.""" + response = ( + api.get("/users/1") + .assert_2xx() + .assert_openapi("getUser") + ) + + def test_create_user_matches_spec(self, api): + """Verify POST /users matches OpenAPI spec.""" + response = ( + api.post("/users", json={ + "name": "John Doe", + "email": "john@example.com" + }) + .assert_status(201) + .assert_openapi("createUser") + ) + + def test_list_users_matches_spec(self, api): + """Verify GET /users matches OpenAPI spec.""" + response = ( + api.get("/users") + .assert_2xx() + .assert_openapi("listUsers") + ) +``` + +#### Regression Testing + +Detect breaking changes when API is updated: + +```python +def test_user_schema_unchanged(api): + """Ensure user schema hasn't changed unexpectedly.""" + response = api.get("/users/1").assert_2xx() + + # OpenAPI validation catches: + # - Missing required fields + # - Wrong data types + # - Invalid enum values + # - Format violations (email, date-time, etc.) + response.assert_openapi("getUser") +``` + +#### Multi-Environment Validation + +Validate different environments against the same spec: + +```python +import pytest +from berapi import BerAPI, Settings + +@pytest.fixture(params=["dev", "staging", "prod"]) +def api(request): + base_urls = { + "dev": "https://dev-api.example.com", + "staging": "https://staging-api.example.com", + "prod": "https://api.example.com", + } + return BerAPI(Settings( + base_url=base_urls[request.param], + openapi_spec_path="openapi.yaml", + )) + +def test_all_environments_match_spec(api): + """All environments should match the API contract.""" + response = api.get("/users/1").assert_2xx().assert_openapi("getUser") +``` + +### Error Handling + +```python +from berapi.exceptions import OpenAPIError + +try: + response = api.get("/users/1").assert_openapi("getUser") +except OpenAPIError as e: + print(f"Validation failed for operation: {e.operation_id}") + print(f"Errors:") + for error in e.errors: + print(f" - {error}") +``` + +**Example Error Output:** +``` +OpenAPI validation failed: + - Response body validation failed: 'email' is a required property + - Content-Type 'text/plain' not in allowed types ['application/json'] +``` + +### Combining with JSON Schema + +You can use both OpenAPI validation and JSON Schema for comprehensive validation: + +```python +response = ( + api.get("/users/1") + .assert_2xx() + .assert_openapi("getUser") # Validate against OpenAPI spec + .assert_json_schema({ # Additional custom validation + "type": "object", + "properties": { + "email": {"pattern": "^[a-z]+@example\\.com$"} # Custom pattern + } + }) +) +``` + +### Best Practices + +1. **Keep specs in version control** - Track changes to your API contract +2. **Use operationId** - Give each operation a unique, descriptive ID +3. **Validate on CI/CD** - Run contract tests in your pipeline +4. **Test error responses** - Validate 4xx/5xx responses too +5. **Update specs first** - Change spec before implementation (contract-first) + +```python +# Test error response schema +def test_not_found_matches_spec(api): + response = api.get("/users/99999").assert_4xx().assert_openapi("getUser") + +def test_validation_error_matches_spec(api): + response = ( + api.post("/users", json={"invalid": "data"}) + .assert_status(422) + .assert_openapi("createUser") + ) +``` + +## Error Handling + +```python +from berapi import BerAPI, Settings +from berapi.exceptions import ( + StatusCodeError, + JsonPathError, + TimeoutError, + RetryExhaustedError, +) + +api = BerAPI(Settings(base_url="https://api.example.com")) + +try: + response = api.get("/users/1").assert_2xx() +except StatusCodeError as e: + print(f"Expected {e.expected}, got {e.actual}") +except JsonPathError as e: + print(f"Path {e.path}: expected {e.expected}, got {e.actual}") +except TimeoutError as e: + print(f"Request timed out after {e.timeout}s") +except RetryExhaustedError as e: + print(f"Failed after {e.attempts} attempts: {e.last_error}") +``` + +## Complete Example + +```python +import pytest +from berapi import BerAPI, Settings +from berapi.middleware import LoggingMiddleware, BearerAuthMiddleware + +@pytest.fixture +def api(): + return BerAPI( + Settings( + base_url="https://jsonplaceholder.typicode.com", + timeout=10.0, + ), + middlewares=[LoggingMiddleware()] + ) + +class TestUserAPI: + def test_get_user(self, api): + response = ( + api.get("/users/1") + .assert_2xx() + .assert_json_path("id", 1) + .assert_has_key("email") + .assert_response_time(2.0) + ) + user = response.to_dict() + assert "name" in user + + def test_create_user(self, api): + response = ( + api.post("/users", json={ + "name": "John Doe", + "email": "john@example.com" + }) + .assert_status(201) + .assert_json_not_empty("id") + ) + user_id = response.get("id") + assert user_id is not None + + def test_list_users(self, api): + response = ( + api.get("/users") + .assert_2xx() + .assert_list_not_empty() + ) + users = response.to_dict() + assert len(users) > 0 + + def test_not_found(self, api): + api.get("/users/99999").assert_4xx() +``` + +## Migration from v1 + +See [MIGRATION.md](MIGRATION.md) for detailed migration guide from v1 to v2. + +## Development ```bash +# Install dependencies pip install poetry poetry install --with test -``` -### Run Test -```bash -poetry run pytest tests +# Run tests +poetry run pytest tests/ + +# Type checking +poetry run mypy src/ ``` -### Building Lib -```bash -poetry build -poetry publish -``` \ No newline at end of file +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/berapi/__init__.py b/berapi_v1_backup/__init__.py similarity index 100% rename from berapi/__init__.py rename to berapi_v1_backup/__init__.py diff --git a/berapi/apy.py b/berapi_v1_backup/apy.py similarity index 100% rename from berapi/apy.py rename to berapi_v1_backup/apy.py diff --git a/berapi/responder.py b/berapi_v1_backup/responder.py similarity index 77% rename from berapi/responder.py rename to berapi_v1_backup/responder.py index 3e3e1cb..eed51f8 100644 --- a/berapi/responder.py +++ b/berapi_v1_backup/responder.py @@ -68,6 +68,21 @@ def assert_not_contains(self, text: str) -> 'Responder': assert_that(self.response.text).does_not_contain(text) return self + def assert_header(self, key: str, value: str) -> 'Responder': + """Assert response header has specific value""" + assert_that(self.response.headers.get(key)).is_equal_to(value) + return self + + def assert_header_exists(self, key: str) -> 'Responder': + """Assert response header exists""" + assert_that(self.response.headers.get(key)).is_not_none() + return self + + def assert_content_type(self, content_type: str) -> 'Responder': + """Assert response Content-Type header contains value""" + assert_that(self.response.headers.get('Content-Type')).contains(content_type) + return self + def get_value(self, key: str): """ Get value for nested key, example get_value('data.user.id') @@ -124,6 +139,31 @@ def assert_value_not_empty(self, key: str) -> 'Responder': assert_that(self.get_property(key)).is_not_none() return self + def assert_has_key(self, key: str) -> 'Responder': + """Assert JSON response has key (supports nested keys with dot notation)""" + if '.' in key: + value = self.get_value(key) + assert_that(value).is_not_none() + else: + assert_that(self.parse_json()).contains_key(key) + return self + + def assert_list_not_empty(self) -> 'Responder': + """Assert response is a non-empty list""" + data = self.parse_json() + assert_that(data).is_instance_of(list) + assert_that(data).is_not_empty() + return self + + def assert_value_in(self, key: str, allowed_values: list) -> 'Responder': + """Assert value is one of the allowed values""" + if '.' in key: + value = self.get_value(key) + else: + value = self.get_property(key) + assert_that(value).is_in(*allowed_values) + return self + def _open_json(self, path_to_json, as_string=False): import os assert os.path.exists(path_to_json), f"JSON not found, Path: {path_to_json}" diff --git a/berapi/utils.py b/berapi_v1_backup/utils.py similarity index 100% rename from berapi/utils.py rename to berapi_v1_backup/utils.py diff --git a/poetry.lock b/poetry.lock index 43274ae..e73d2d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -165,19 +165,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "curlify" -version = "2.2.1" -description = "Library to convert python requests object to curl command." -optional = false -python-versions = "*" -files = [ - {file = "curlify-2.2.1.tar.gz", hash = "sha256:0d3f02e7235faf952de8ef45ef469845196d30632d5838bcd5aee217726ddd6d"}, -] - -[package.dependencies] -requests = "*" - [[package]] name = "faker" version = "37.0.0" @@ -228,6 +215,17 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isodate" +version = "0.7.2" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, + {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -266,6 +264,23 @@ rpds-py = ">=0.7.1" format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] +[[package]] +name = "jsonschema-path" +version = "0.3.4" +description = "JSONSchema Spec with object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.8.0" +files = [ + {file = "jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8"}, + {file = "jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001"}, +] + +[package.dependencies] +pathable = ">=0.4.1,<0.5.0" +PyYAML = ">=5.1" +referencing = "<0.37.0" +requests = ">=2.31.0,<3.0.0" + [[package]] name = "jsonschema-specifications" version = "2024.10.1" @@ -280,6 +295,59 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +description = "A fast and thorough lazy object proxy." +optional = false +python-versions = ">=3.9" +files = [ + {file = "lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519"}, + {file = "lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6"}, + {file = "lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01c7819a410f7c255b20799b65d36b414379a30c6f1684c7bd7eb6777338c1b"}, + {file = "lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:029d2b355076710505c9545aef5ab3f750d89779310e26ddf2b7b23f6ea03cd8"}, + {file = "lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc6e3614eca88b1c8a625fc0a47d0d745e7c3255b21dac0e30b3037c5e3deeb8"}, + {file = "lazy_object_proxy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:be5fe974e39ceb0d6c9db0663c0464669cf866b2851c73971409b9566e880eab"}, + {file = "lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff"}, + {file = "lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad"}, + {file = "lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00"}, + {file = "lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508"}, + {file = "lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa"}, + {file = "lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370"}, + {file = "lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede"}, + {file = "lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9"}, + {file = "lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0"}, + {file = "lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308"}, + {file = "lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23"}, + {file = "lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac"}, + {file = "lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f"}, + {file = "lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3"}, + {file = "lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a"}, + {file = "lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a"}, + {file = "lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95"}, + {file = "lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5"}, + {file = "lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f"}, + {file = "lazy_object_proxy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ae575ad9b674d0029fc077c5231b3bc6b433a3d1a62a8c363df96974b5534728"}, + {file = "lazy_object_proxy-1.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31020c84005d3daa4cc0fa5a310af2066efe6b0d82aeebf9ab199292652ff036"}, + {file = "lazy_object_proxy-1.12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800f32b00a47c27446a2b767df7538e6c66a3488632c402b4fb2224f9794f3c0"}, + {file = "lazy_object_proxy-1.12.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:15400b18893f345857b9e18b9bd87bd06aba84af6ed086187add70aeaa3f93f1"}, + {file = "lazy_object_proxy-1.12.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3d3964fbd326578bcdfffd017ef101b6fb0484f34e731fe060ba9b8816498c36"}, + {file = "lazy_object_proxy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:424a8ab6695400845c39f13c685050eab69fa0bbac5790b201cd27375e5e41d7"}, + {file = "lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402"}, + {file = "lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61"}, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -350,6 +418,81 @@ files = [ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + +[[package]] +name = "openapi-core" +version = "0.19.5" +description = "client-side and server-side support for the OpenAPI Specification v3" +optional = false +python-versions = "<4.0.0,>=3.8.0" +files = [ + {file = "openapi_core-0.19.5-py3-none-any.whl", hash = "sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f"}, + {file = "openapi_core-0.19.5.tar.gz", hash = "sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3"}, +] + +[package.dependencies] +isodate = "*" +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-path = ">=0.3.1,<0.4.0" +more-itertools = "*" +openapi-schema-validator = ">=0.6.0,<0.7.0" +openapi-spec-validator = ">=0.7.1,<0.8.0" +parse = "*" +typing-extensions = ">=4.8.0,<5.0.0" +werkzeug = "<3.1.2" + +[package.extras] +aiohttp = ["aiohttp (>=3.0)", "multidict (>=6.0.4,<7.0.0)"] +django = ["django (>=3.0)"] +falcon = ["falcon (>=3.0)"] +fastapi = ["fastapi (>=0.111,<0.116)"] +flask = ["flask"] +requests = ["requests"] +starlette = ["aioitertools (>=0.11,<0.13)", "starlette (>=0.26.1,<0.45.0)"] + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +description = "OpenAPI schema validation for Python" +optional = false +python-versions = "<4.0.0,>=3.8.0" +files = [ + {file = "openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3"}, + {file = "openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee"}, +] + +[package.dependencies] +jsonschema = ">=4.19.1,<5.0.0" +jsonschema-specifications = ">=2023.5.2" +rfc3339-validator = "*" + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" +optional = false +python-versions = "<4.0.0,>=3.8.0" +files = [ + {file = "openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60"}, + {file = "openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734"}, +] + +[package.dependencies] +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-path = ">=0.3.1,<0.4.0" +lazy-object-proxy = ">=1.7.1,<2.0.0" +openapi-schema-validator = ">=0.6.0,<0.7.0" + [[package]] name = "packaging" version = "24.2" @@ -361,6 +504,28 @@ files = [ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] +[[package]] +name = "parse" +version = "1.20.2" +description = "parse() is the opposite of format()" +optional = false +python-versions = "*" +files = [ + {file = "parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558"}, + {file = "parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce"}, +] + +[[package]] +name = "pathable" +version = "0.4.4" +description = "Object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.7.0" +files = [ + {file = "pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2"}, + {file = "pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2"}, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -433,6 +598,88 @@ pytest = ">=7.0.0" [package.extras] test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"] +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "referencing" version = "0.35.1" @@ -469,6 +716,39 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "responses" +version = "0.25.8" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c"}, + {file = "responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "rpds-py" version = "0.21.0" @@ -568,6 +848,45 @@ files = [ {file = "rpds_py-0.21.0.tar.gz", hash = "sha256:ed6378c9d66d0de903763e7706383d60c33829581f0adff47b6535f1802fa6db"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "structlog" +version = "24.4.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610"}, + {file = "structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4"}, +] + +[package.extras] +dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] +tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] +typing = ["mypy (>=1.4)", "rich", "twisted"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + [[package]] name = "tzdata" version = "2025.1" @@ -596,7 +915,24 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "werkzeug" +version = "3.1.1" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +files = [ + {file = "werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5"}, + {file = "werkzeug-3.1.1.tar.gz", hash = "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "b3e9d92fd93f46c4753ef15264bdf352327520a5435c70763a28738e7abb9819" +content-hash = "24abac1fc039d4eccb02400a4bbcca6fd1b36de6e059f4e517c9a8c054cf0e0f" diff --git a/pyproject.toml b/pyproject.toml index 429b672..f183684 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,54 @@ [tool.poetry] name = "berapi" -version = "0.1.9" -description = "An API client for simplifying API testing with Python + PyTest" +version = "2.0.0" +description = "A modern, scalable API testing library for Python with middleware support, structured logging, and fluent assertions" authors = ["fachrulch "] license = "MIT" readme = "README.md" +homepage = "https://github.com/fachrulch/berapi" +repository = "https://github.com/fachrulch/berapi" +documentation = "https://github.com/fachrulch/berapi#readme" +packages = [{include = "berapi", from = "src"}] +keywords = ["api", "testing", "pytest", "http", "rest", "api-client", "middleware", "assertions"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Testing", + "Topic :: Internet :: WWW/HTTP", + "Typing :: Typed", +] [tool.poetry.dependencies] python = "^3.12" requests = "^2.32.3" -curlify = "^2.2.1" -assertpy = "^1.1" +structlog = "^24.1.0" jsonschema = "^4.23.0" genson = "^1.3.0" - +openapi-core = "^0.19.0" +pyyaml = "^6.0.1" [tool.poetry.group.test.dependencies] pytest = "^8.3.5" pytest-html = "^4.1.1" faker = "^37.0.0" +responses = "^0.25.0" +assertpy = "^1.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_cli = true +log_cli_level = "INFO" +log_cli_format = "%(message)s" + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_configs = true diff --git a/src/berapi/__init__.py b/src/berapi/__init__.py new file mode 100644 index 0000000..58f9c59 --- /dev/null +++ b/src/berapi/__init__.py @@ -0,0 +1,94 @@ +"""BerAPI - Modern API Testing Library for Python. + +A modern, scalable API testing library with middleware support, +structured logging, and fluent assertions. + +Example: + >>> from berapi import BerAPI, Settings + >>> from berapi.middleware import LoggingMiddleware + >>> + >>> api = BerAPI( + ... Settings(base_url="https://api.example.com"), + ... middlewares=[LoggingMiddleware()] + ... ) + >>> + >>> response = ( + ... api.get("/users/1") + ... .assert_2xx() + ... .assert_json_path("name", "John") + ... ) + >>> user = response.to_dict() +""" + +from berapi.client import BerAPI +from berapi.config.settings import Settings, LoggingSettings, RetrySettings +from berapi.response.response import Response + +# Re-export commonly used middleware +from berapi.middleware import ( + Middleware, + RequestContext, + ResponseContext, + MiddlewareChain, + LoggingMiddleware, + BearerAuthMiddleware, + ApiKeyMiddleware, +) + +# Re-export exceptions +from berapi.exceptions import ( + BerAPIError, + HTTPError, + RequestError, + ConnectionError, + TimeoutError, + ResponseTimeError, + AssertionError, + StatusCodeError, + HeaderError, + JsonPathError, + ValidationError, + JsonSchemaError, + OpenAPIError, + ConfigurationError, + RetryExhaustedError, +) + +__version__ = "2.0.0" + +__all__ = [ + # Main client + "BerAPI", + # Configuration + "Settings", + "LoggingSettings", + "RetrySettings", + # Response + "Response", + # Middleware + "Middleware", + "RequestContext", + "ResponseContext", + "MiddlewareChain", + "LoggingMiddleware", + "BearerAuthMiddleware", + "ApiKeyMiddleware", + # Exceptions + "BerAPIError", + "HTTPError", + "RequestError", + "ConnectionError", + "TimeoutError", + "ResponseTimeError", + "AssertionError", + "StatusCodeError", + "HeaderError", + "JsonPathError", + "ValidationError", + "JsonSchemaError", + "OpenAPIError", + "ConfigurationError", + "RetryExhaustedError", + # Version + "__version__", +] diff --git a/src/berapi/client.py b/src/berapi/client.py new file mode 100644 index 0000000..e5c987e --- /dev/null +++ b/src/berapi/client.py @@ -0,0 +1,319 @@ +"""Main BerAPI client class.""" + +from __future__ import annotations + +from typing import Any, Self + +from berapi.config.settings import Settings +from berapi.http.session import HttpSession +from berapi.logging.setup import configure_logging +from berapi.middleware.base import Middleware +from berapi.middleware.chain import MiddlewareChain +from berapi.response.response import Response + + +class BerAPI: + """Modern API client for testing with fluent assertions. + + The main entry point for making HTTP requests with built-in + middleware support, structured logging, and fluent assertions. + + Example: + >>> from berapi import BerAPI, Settings + >>> from berapi.middleware import LoggingMiddleware + >>> + >>> api = BerAPI( + ... Settings(base_url="https://api.example.com"), + ... middlewares=[LoggingMiddleware()] + ... ) + >>> + >>> response = ( + ... api.get("/users/1") + ... .assert_2xx() + ... .assert_json_path("name", "John") + ... ) + """ + + def __init__( + self, + settings: Settings | None = None, + middlewares: list[Middleware] | None = None, + ) -> None: + """Initialize BerAPI client. + + Args: + settings: Configuration settings. If None, loads from environment. + middlewares: List of middleware to use. + """ + self._settings = settings or Settings.from_env() + + # Configure logging + configure_logging(self._settings.logging) + + # Setup middleware chain + self._middleware_chain = MiddlewareChain(middlewares) + + # Create HTTP session + self._session = HttpSession( + settings=self._settings, + middleware_chain=self._middleware_chain, + ) + + @property + def settings(self) -> Settings: + """Get current settings.""" + return self._settings + + def add_middleware(self, middleware: Middleware) -> Self: + """Add middleware to the client. + + Args: + middleware: Middleware to add. + + Returns: + Self for chaining. + """ + self._middleware_chain.add(middleware) + return self + + def with_settings(self, **overrides: Any) -> BerAPI: + """Create new client with settings overrides. + + Args: + **overrides: Settings to override. + + Returns: + New BerAPI instance with overridden settings. + """ + new_settings = self._settings.merge(overrides) + return BerAPI( + settings=new_settings, + middlewares=list(self._middleware_chain._middlewares), + ) + + # === HTTP Methods === + + def get( + self, + url: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> Response: + """Make a GET request. + + Args: + url: URL to request (can be relative if base_url is set). + params: Query parameters. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + Response wrapper with fluent assertions. + """ + response_ctx = self._session.request( + method="GET", + url=url, + params=params, + headers=headers, + timeout=timeout, + **kwargs, + ) + return Response(response_ctx.response) + + def post( + self, + url: str, + json: dict[str, Any] | list[Any] | None = None, + data: Any | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> Response: + """Make a POST request. + + Args: + url: URL to request. + json: JSON body to send. + data: Form data to send. + params: Query parameters. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + Response wrapper with fluent assertions. + """ + response_ctx = self._session.request( + method="POST", + url=url, + json=json, + data=data, + params=params, + headers=headers, + timeout=timeout, + **kwargs, + ) + return Response(response_ctx.response) + + def put( + self, + url: str, + json: dict[str, Any] | list[Any] | None = None, + data: Any | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> Response: + """Make a PUT request. + + Args: + url: URL to request. + json: JSON body to send. + data: Form data to send. + params: Query parameters. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + Response wrapper with fluent assertions. + """ + response_ctx = self._session.request( + method="PUT", + url=url, + json=json, + data=data, + params=params, + headers=headers, + timeout=timeout, + **kwargs, + ) + return Response(response_ctx.response) + + def patch( + self, + url: str, + json: dict[str, Any] | list[Any] | None = None, + data: Any | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> Response: + """Make a PATCH request. + + Args: + url: URL to request. + json: JSON body to send. + data: Form data to send. + params: Query parameters. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + Response wrapper with fluent assertions. + """ + response_ctx = self._session.request( + method="PATCH", + url=url, + json=json, + data=data, + params=params, + headers=headers, + timeout=timeout, + **kwargs, + ) + return Response(response_ctx.response) + + def delete( + self, + url: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> Response: + """Make a DELETE request. + + Args: + url: URL to request. + params: Query parameters. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + Response wrapper with fluent assertions. + """ + response_ctx = self._session.request( + method="DELETE", + url=url, + params=params, + headers=headers, + timeout=timeout, + **kwargs, + ) + return Response(response_ctx.response) + + def request( + self, + method: str, + url: str, + json: dict[str, Any] | list[Any] | None = None, + data: Any | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> Response: + """Make a request with any HTTP method. + + Args: + method: HTTP method (GET, POST, PUT, PATCH, DELETE, etc.). + url: URL to request. + json: JSON body to send. + data: Form data to send. + params: Query parameters. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + Response wrapper with fluent assertions. + """ + response_ctx = self._session.request( + method=method, + url=url, + json=json, + data=data, + params=params, + headers=headers, + timeout=timeout, + **kwargs, + ) + return Response(response_ctx.response) + + # === Context Manager === + + def close(self) -> None: + """Close the client session.""" + self._session.close() + + def __enter__(self) -> BerAPI: + """Enter context manager.""" + return self + + def __exit__(self, *args: Any) -> None: + """Exit context manager.""" + self.close() + + def __repr__(self) -> str: + """String representation.""" + base_url = self._settings.base_url or "" + return f"" diff --git a/src/berapi/config/__init__.py b/src/berapi/config/__init__.py new file mode 100644 index 0000000..651e74a --- /dev/null +++ b/src/berapi/config/__init__.py @@ -0,0 +1,13 @@ +"""Configuration management for BerAPI.""" + +from berapi.config.settings import ( + Settings, + LoggingSettings, + RetrySettings, +) + +__all__ = [ + "Settings", + "LoggingSettings", + "RetrySettings", +] diff --git a/src/berapi/config/settings.py b/src/berapi/config/settings.py new file mode 100644 index 0000000..922cd15 --- /dev/null +++ b/src/berapi/config/settings.py @@ -0,0 +1,128 @@ +"""Settings dataclasses for BerAPI configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any + + +def _parse_optional_float(value: str | None) -> float | None: + """Parse optional float from string.""" + if value is None: + return None + try: + return float(value) + except ValueError: + return None + + +def _parse_bool(value: str | None, default: bool = True) -> bool: + """Parse boolean from string.""" + if value is None: + return default + return value.lower() in ("true", "1", "yes", "on") + + +@dataclass +class LoggingSettings: + """Logging configuration.""" + + level: str = "INFO" + format: str = "json" # "json" or "console" + log_request_body: bool = True + log_response_body: bool = True + log_headers: bool = True + log_curl: bool = True + redact_headers: frozenset[str] = field( + default_factory=lambda: frozenset( + {"authorization", "x-api-key", "cookie", "x-auth-token"} + ) + ) + + +@dataclass +class RetrySettings: + """Retry configuration.""" + + enabled: bool = True + max_retries: int = 3 + backoff_factor: float = 0.5 + backoff_max: float = 60.0 + retry_statuses: frozenset[int] = field( + default_factory=lambda: frozenset({429, 500, 502, 503, 504}) + ) + jitter: bool = True + + +@dataclass +class Settings: + """Main configuration for BerAPI client.""" + + base_url: str | None = None + timeout: float = 30.0 + max_response_time: float | None = None # None = no limit + verify_ssl: bool = True + + headers: dict[str, str] = field(default_factory=dict) + + logging: LoggingSettings = field(default_factory=LoggingSettings) + retry: RetrySettings = field(default_factory=RetrySettings) + + # OpenAPI validation + openapi_spec_path: str | None = None + openapi_validate_requests: bool = False + openapi_validate_responses: bool = False + + @classmethod + def from_env(cls) -> Settings: + """Create settings from environment variables.""" + return cls( + base_url=os.getenv("BERAPI_BASE_URL"), + timeout=float(os.getenv("BERAPI_TIMEOUT", "30.0")), + max_response_time=_parse_optional_float( + os.getenv("BERAPI_MAX_RESPONSE_TIME") + ), + verify_ssl=_parse_bool(os.getenv("BERAPI_VERIFY_SSL"), default=True), + logging=LoggingSettings( + level=os.getenv("BERAPI_LOG_LEVEL", "INFO"), + format=os.getenv("BERAPI_LOG_FORMAT", "json"), + log_request_body=_parse_bool( + os.getenv("BERAPI_LOG_REQUEST_BODY"), default=True + ), + log_response_body=_parse_bool( + os.getenv("BERAPI_LOG_RESPONSE_BODY"), default=True + ), + log_headers=_parse_bool(os.getenv("BERAPI_LOG_HEADERS"), default=True), + log_curl=_parse_bool(os.getenv("BERAPI_LOG_CURL"), default=True), + ), + retry=RetrySettings( + enabled=_parse_bool(os.getenv("BERAPI_RETRY_ENABLED"), default=True), + max_retries=int(os.getenv("BERAPI_MAX_RETRIES", "3")), + backoff_factor=float(os.getenv("BERAPI_BACKOFF_FACTOR", "0.5")), + backoff_max=float(os.getenv("BERAPI_BACKOFF_MAX", "60.0")), + jitter=_parse_bool(os.getenv("BERAPI_RETRY_JITTER"), default=True), + ), + openapi_spec_path=os.getenv("BERAPI_OPENAPI_SPEC"), + ) + + def merge(self, overrides: dict[str, Any]) -> Settings: + """Create new settings with overrides applied.""" + import copy + + new_settings = copy.deepcopy(self) + + for key, value in overrides.items(): + if hasattr(new_settings, key): + if key == "logging" and isinstance(value, dict): + for log_key, log_value in value.items(): + if hasattr(new_settings.logging, log_key): + setattr(new_settings.logging, log_key, log_value) + elif key == "retry" and isinstance(value, dict): + for retry_key, retry_value in value.items(): + if hasattr(new_settings.retry, retry_key): + setattr(new_settings.retry, retry_key, retry_value) + else: + setattr(new_settings, key, value) + + return new_settings diff --git a/src/berapi/exceptions/__init__.py b/src/berapi/exceptions/__init__.py new file mode 100644 index 0000000..ba45a0f --- /dev/null +++ b/src/berapi/exceptions/__init__.py @@ -0,0 +1,37 @@ +"""BerAPI custom exceptions.""" + +from berapi.exceptions.errors import ( + BerAPIError, + HTTPError, + RequestError, + ConnectionError, + TimeoutError, + ResponseTimeError, + AssertionError, + StatusCodeError, + HeaderError, + JsonPathError, + ValidationError, + JsonSchemaError, + OpenAPIError, + ConfigurationError, + RetryExhaustedError, +) + +__all__ = [ + "BerAPIError", + "HTTPError", + "RequestError", + "ConnectionError", + "TimeoutError", + "ResponseTimeError", + "AssertionError", + "StatusCodeError", + "HeaderError", + "JsonPathError", + "ValidationError", + "JsonSchemaError", + "OpenAPIError", + "ConfigurationError", + "RetryExhaustedError", +] diff --git a/src/berapi/exceptions/errors.py b/src/berapi/exceptions/errors.py new file mode 100644 index 0000000..31b6e06 --- /dev/null +++ b/src/berapi/exceptions/errors.py @@ -0,0 +1,187 @@ +"""Custom exception hierarchy for BerAPI.""" + +from typing import Any + + +class BerAPIError(Exception): + """Base exception for all BerAPI errors.""" + + def __init__(self, message: str, **context: Any) -> None: + super().__init__(message) + self.message = message + self.context = context + + def __str__(self) -> str: + if self.context: + ctx = ", ".join(f"{k}={v!r}" for k, v in self.context.items()) + return f"{self.message} ({ctx})" + return self.message + + +# === HTTP Errors === + + +class HTTPError(BerAPIError): + """Base class for HTTP-related errors.""" + + pass + + +class RequestError(HTTPError): + """Error occurred while making the request.""" + + pass + + +class ConnectionError(RequestError): + """Failed to connect to the server.""" + + pass + + +class TimeoutError(RequestError): + """Request timed out.""" + + def __init__(self, message: str, timeout: float, **context: Any) -> None: + super().__init__(message, timeout=timeout, **context) + self.timeout = timeout + + +class ResponseTimeError(HTTPError): + """Response took longer than allowed threshold.""" + + def __init__( + self, + message: str, + elapsed: float, + threshold: float, + **context: Any, + ) -> None: + super().__init__(message, elapsed=elapsed, threshold=threshold, **context) + self.elapsed = elapsed + self.threshold = threshold + + +# === Assertion Errors === + + +class AssertionError(BerAPIError): + """Base class for assertion failures.""" + + pass + + +class StatusCodeError(AssertionError): + """Status code assertion failed.""" + + def __init__( + self, + message: str, + expected: int | tuple[int, int], + actual: int, + **context: Any, + ) -> None: + super().__init__(message, expected=expected, actual=actual, **context) + self.expected = expected + self.actual = actual + + +class HeaderError(AssertionError): + """Header assertion failed.""" + + def __init__( + self, + message: str, + header: str, + expected: str | None = None, + actual: str | None = None, + **context: Any, + ) -> None: + super().__init__( + message, header=header, expected=expected, actual=actual, **context + ) + self.header = header + self.expected = expected + self.actual = actual + + +class JsonPathError(AssertionError): + """JSON path assertion failed.""" + + def __init__( + self, + message: str, + path: str, + expected: Any, + actual: Any, + **context: Any, + ) -> None: + super().__init__(message, path=path, expected=expected, actual=actual, **context) + self.path = path + self.expected = expected + self.actual = actual + + +# === Validation Errors === + + +class ValidationError(BerAPIError): + """Base class for validation errors.""" + + def __init__(self, message: str, errors: list[str], **context: Any) -> None: + super().__init__(message, **context) + self.errors = errors + + def __str__(self) -> str: + if self.errors: + errors_str = "\n - ".join(self.errors) + return f"{self.message}:\n - {errors_str}" + return self.message + + +class JsonSchemaError(ValidationError): + """JSON Schema validation failed.""" + + pass + + +class OpenAPIError(ValidationError): + """OpenAPI validation failed.""" + + def __init__( + self, + message: str, + errors: list[str], + operation_id: str | None = None, + **context: Any, + ) -> None: + super().__init__(message, errors, operation_id=operation_id, **context) + self.operation_id = operation_id + + +# === Configuration Errors === + + +class ConfigurationError(BerAPIError): + """Invalid configuration.""" + + pass + + +# === Retry Errors === + + +class RetryExhaustedError(HTTPError): + """All retry attempts exhausted.""" + + def __init__( + self, + message: str, + attempts: int, + last_error: Exception, + **context: Any, + ) -> None: + super().__init__(message, attempts=attempts, **context) + self.attempts = attempts + self.last_error = last_error + self.__cause__ = last_error diff --git a/src/berapi/http/__init__.py b/src/berapi/http/__init__.py new file mode 100644 index 0000000..b59861d --- /dev/null +++ b/src/berapi/http/__init__.py @@ -0,0 +1,10 @@ +"""HTTP client layer for BerAPI.""" + +from berapi.http.retry import RetryConfig, RetryHandler +from berapi.http.session import HttpSession + +__all__ = [ + "RetryConfig", + "RetryHandler", + "HttpSession", +] diff --git a/src/berapi/http/retry.py b/src/berapi/http/retry.py new file mode 100644 index 0000000..4fbd5e5 --- /dev/null +++ b/src/berapi/http/retry.py @@ -0,0 +1,126 @@ +"""Retry handler with exponential backoff.""" + +from __future__ import annotations + +import random +import time +from dataclasses import dataclass, field +from typing import Callable, TypeVar + +from berapi.exceptions.errors import RetryExhaustedError +from berapi.logging.setup import get_logger + +T = TypeVar("T") + + +@dataclass +class RetryConfig: + """Configuration for retry behavior.""" + + max_retries: int = 3 + backoff_factor: float = 0.5 + backoff_max: float = 60.0 + retry_statuses: frozenset[int] = field( + default_factory=lambda: frozenset({429, 500, 502, 503, 504}) + ) + retry_exceptions: tuple[type[Exception], ...] = field( + default_factory=lambda: (ConnectionError, TimeoutError) + ) + jitter: bool = True # Add randomness to prevent thundering herd + + +class RetryHandler: + """Handles retry logic with exponential backoff.""" + + def __init__(self, config: RetryConfig | None = None) -> None: + """Initialize retry handler. + + Args: + config: Retry configuration. Uses defaults if not provided. + """ + self.config = config or RetryConfig() + self._logger = get_logger("berapi.retry") + + def execute( + self, + func: Callable[[], T], + on_retry: Callable[[int, Exception, float], None] | None = None, + ) -> T: + """Execute function with retry logic. + + Args: + func: Function to execute. + on_retry: Optional callback called before each retry with + (attempt_number, exception, delay_seconds). + + Returns: + Result of the function. + + Raises: + RetryExhaustedError: If all retry attempts are exhausted. + """ + last_exception: Exception | None = None + + for attempt in range(self.config.max_retries + 1): + try: + return func() + except self.config.retry_exceptions as e: + last_exception = e + + if attempt < self.config.max_retries: + delay = self._calculate_delay(attempt) + + self._logger.warning( + "retry_attempt", + attempt=attempt + 1, + max_retries=self.config.max_retries, + error_type=type(e).__name__, + error_message=str(e), + delay_seconds=round(delay, 2), + ) + + if on_retry: + on_retry(attempt + 1, e, delay) + + time.sleep(delay) + + if last_exception is not None: + raise RetryExhaustedError( + f"All {self.config.max_retries} retry attempts exhausted", + attempts=self.config.max_retries, + last_error=last_exception, + ) + + # This should never happen, but satisfies type checker + raise RuntimeError("Unexpected state in retry handler") + + def should_retry_status(self, status_code: int) -> bool: + """Check if status code should trigger retry. + + Args: + status_code: HTTP status code. + + Returns: + True if the status code should trigger a retry. + """ + return status_code in self.config.retry_statuses + + def _calculate_delay(self, attempt: int) -> float: + """Calculate delay with exponential backoff and optional jitter. + + Args: + attempt: Current attempt number (0-indexed). + + Returns: + Delay in seconds. + """ + delay = min( + self.config.backoff_factor * (2**attempt), + self.config.backoff_max, + ) + + if self.config.jitter: + # Add 0-100% jitter + delay *= 0.5 + random.random() + + return delay diff --git a/src/berapi/http/session.py b/src/berapi/http/session.py new file mode 100644 index 0000000..8f2af0a --- /dev/null +++ b/src/berapi/http/session.py @@ -0,0 +1,197 @@ +"""HTTP session management.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +import requests + +from berapi.exceptions.errors import ( + ConnectionError as BerAPIConnectionError, + ResponseTimeError, + TimeoutError as BerAPITimeoutError, +) +from berapi.http.retry import RetryConfig, RetryHandler +from berapi.middleware.base import RequestContext, ResponseContext +from berapi.middleware.chain import MiddlewareChain + +if TYPE_CHECKING: + from berapi.config.settings import Settings + + +class HttpSession: + """HTTP session with middleware and retry support.""" + + def __init__( + self, + settings: Settings, + middleware_chain: MiddlewareChain | None = None, + ) -> None: + """Initialize HTTP session. + + Args: + settings: Configuration settings. + middleware_chain: Middleware chain to use. + """ + self._settings = settings + self._session = requests.Session() + self._middleware_chain = middleware_chain if middleware_chain is not None else MiddlewareChain() + + # Configure session + self._session.headers.update(settings.headers) + self._session.verify = settings.verify_ssl + + # Setup retry handler if enabled + self._retry_handler: RetryHandler | None = None + if settings.retry.enabled: + self._retry_handler = RetryHandler( + RetryConfig( + max_retries=settings.retry.max_retries, + backoff_factor=settings.retry.backoff_factor, + backoff_max=settings.retry.backoff_max, + retry_statuses=settings.retry.retry_statuses, + jitter=settings.retry.jitter, + ) + ) + + def request( + self, + method: str, + url: str, + params: dict[str, Any] | None = None, + data: Any | None = None, + json: dict[str, Any] | list[Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> ResponseContext: + """Make an HTTP request. + + Args: + method: HTTP method (GET, POST, etc.). + url: URL to request. Can be relative if base_url is set. + params: Query parameters. + data: Request body data. + json: JSON request body. + headers: Additional headers. + timeout: Request timeout in seconds. + **kwargs: Additional arguments passed to requests. + + Returns: + ResponseContext with the response. + + Raises: + BerAPIConnectionError: If connection fails. + BerAPITimeoutError: If request times out. + ResponseTimeError: If response time exceeds threshold. + """ + # Resolve URL + full_url = self._resolve_url(url) + + # Build request context + request_headers = {**self._session.headers, **(headers or {})} + request_timeout = timeout or self._settings.timeout + + context = RequestContext( + method=method.upper(), + url=full_url, + headers=request_headers, + params=params, + data=data, + json_body=json, + timeout=request_timeout, + ) + + # Execute middleware for request + context = self._middleware_chain.execute_request(context) + + # Define the actual request function + def make_request() -> requests.Response: + try: + response = self._session.request( + method=context.method, + url=context.url, + params=context.params, + data=context.data, + json=context.json_body, + headers=context.headers, + timeout=context.timeout, + **kwargs, + ) + return response + except requests.exceptions.ConnectionError as e: + raise BerAPIConnectionError( + f"Failed to connect to {context.url}", + url=context.url, + ) from e + except requests.exceptions.Timeout as e: + raise BerAPITimeoutError( + f"Request to {context.url} timed out", + timeout=context.timeout or self._settings.timeout, + url=context.url, + ) from e + + # Execute with retry if enabled + try: + if self._retry_handler: + response = self._retry_handler.execute(make_request) + else: + response = make_request() + except Exception as e: + self._middleware_chain.handle_error(e, context) + raise + + # Check response time threshold + if self._settings.max_response_time is not None: + elapsed = response.elapsed.total_seconds() + if elapsed > self._settings.max_response_time: + error = ResponseTimeError( + f"Response time {elapsed:.2f}s exceeded threshold " + f"{self._settings.max_response_time}s", + elapsed=elapsed, + threshold=self._settings.max_response_time, + url=context.url, + ) + self._middleware_chain.handle_error(error, context) + raise error + + # Build response context + response_context = ResponseContext( + response=response, + request_context=context, + ) + + # Execute middleware for response + response_context = self._middleware_chain.execute_response(response_context) + + return response_context + + def _resolve_url(self, url: str) -> str: + """Resolve URL, joining with base_url if relative. + + Args: + url: URL to resolve. + + Returns: + Resolved URL. + """ + if url.startswith(("http://", "https://")): + return url + + if self._settings.base_url: + return urljoin(self._settings.base_url, url) + + return url + + def close(self) -> None: + """Close the session.""" + self._session.close() + + def __enter__(self) -> HttpSession: + """Enter context manager.""" + return self + + def __exit__(self, *args: Any) -> None: + """Exit context manager.""" + self.close() diff --git a/src/berapi/logging/__init__.py b/src/berapi/logging/__init__.py new file mode 100644 index 0000000..57c2117 --- /dev/null +++ b/src/berapi/logging/__init__.py @@ -0,0 +1,8 @@ +"""Structured logging setup for BerAPI.""" + +from berapi.logging.setup import configure_logging, get_logger + +__all__ = [ + "configure_logging", + "get_logger", +] diff --git a/src/berapi/logging/setup.py b/src/berapi/logging/setup.py new file mode 100644 index 0000000..e0187ac --- /dev/null +++ b/src/berapi/logging/setup.py @@ -0,0 +1,85 @@ +"""Structlog configuration for BerAPI.""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from berapi.config.settings import LoggingSettings + +_configured = False + + +def configure_logging(settings: LoggingSettings | None = None) -> None: + """Configure structlog with the given settings. + + Args: + settings: Logging settings. If None, uses defaults. + """ + global _configured + + if _configured: + return + + if settings is None: + from berapi.config.settings import LoggingSettings + + settings = LoggingSettings() + + # Set up standard logging + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, settings.level.upper(), logging.INFO), + ) + + # Common processors + processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if settings.format == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append( + structlog.dev.ConsoleRenderer( + colors=True, + exception_formatter=structlog.dev.plain_traceback, + ) + ) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, settings.level.upper(), logging.INFO) + ), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + + _configured = True + + +def get_logger(name: str | None = None) -> structlog.BoundLogger: + """Get a configured logger instance. + + Args: + name: Logger name. Defaults to 'berapi'. + + Returns: + Configured structlog BoundLogger. + """ + if not _configured: + configure_logging() + + return structlog.get_logger(name or "berapi") diff --git a/src/berapi/middleware/__init__.py b/src/berapi/middleware/__init__.py new file mode 100644 index 0000000..7dc274a --- /dev/null +++ b/src/berapi/middleware/__init__.py @@ -0,0 +1,23 @@ +"""Middleware system for BerAPI.""" + +from berapi.middleware.base import ( + Middleware, + RequestContext, + ResponseContext, +) +from berapi.middleware.chain import MiddlewareChain +from berapi.middleware.logging import LoggingMiddleware +from berapi.middleware.auth import ( + BearerAuthMiddleware, + ApiKeyMiddleware, +) + +__all__ = [ + "Middleware", + "RequestContext", + "ResponseContext", + "MiddlewareChain", + "LoggingMiddleware", + "BearerAuthMiddleware", + "ApiKeyMiddleware", +] diff --git a/src/berapi/middleware/auth.py b/src/berapi/middleware/auth.py new file mode 100644 index 0000000..3755821 --- /dev/null +++ b/src/berapi/middleware/auth.py @@ -0,0 +1,154 @@ +"""Authentication middleware implementations.""" + +from __future__ import annotations + +from typing import Callable + +from berapi.middleware.base import RequestContext, ResponseContext + + +class BearerAuthMiddleware: + """Adds Bearer token authentication to requests.""" + + def __init__(self, token: str | Callable[[], str]) -> None: + """Initialize Bearer auth middleware. + + Args: + token: Static token string or callable that returns token + (for dynamic/refreshable tokens). + """ + self._token = token + + def process_request(self, context: RequestContext) -> RequestContext: + """Add Authorization header with Bearer token. + + Args: + context: Request context. + + Returns: + Context with Authorization header added. + """ + token = self._token() if callable(self._token) else self._token + return context.with_header("Authorization", f"Bearer {token}") + + def process_response(self, context: ResponseContext) -> ResponseContext: + """Pass through response unchanged. + + Args: + context: Response context. + + Returns: + Unchanged response context. + """ + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + """No-op error handler. + + Args: + error: The exception that occurred. + context: Request context. + """ + pass + + +class ApiKeyMiddleware: + """Adds API key authentication to requests.""" + + def __init__( + self, + api_key: str | Callable[[], str], + header_name: str = "X-API-Key", + prefix: str = "", + ) -> None: + """Initialize API key middleware. + + Args: + api_key: API key string or callable that returns the key. + header_name: Name of the header to use. + prefix: Optional prefix for the key value. + """ + self._api_key = api_key + self._header_name = header_name + self._prefix = prefix + + def process_request(self, context: RequestContext) -> RequestContext: + """Add API key header. + + Args: + context: Request context. + + Returns: + Context with API key header added. + """ + key = self._api_key() if callable(self._api_key) else self._api_key + value = f"{self._prefix}{key}" if self._prefix else key + return context.with_header(self._header_name, value) + + def process_response(self, context: ResponseContext) -> ResponseContext: + """Pass through response unchanged. + + Args: + context: Response context. + + Returns: + Unchanged response context. + """ + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + """No-op error handler. + + Args: + error: The exception that occurred. + context: Request context. + """ + pass + + +class BasicAuthMiddleware: + """Adds Basic authentication to requests.""" + + def __init__(self, username: str, password: str) -> None: + """Initialize Basic auth middleware. + + Args: + username: Username for authentication. + password: Password for authentication. + """ + import base64 + + credentials = f"{username}:{password}" + encoded = base64.b64encode(credentials.encode()).decode() + self._auth_header = f"Basic {encoded}" + + def process_request(self, context: RequestContext) -> RequestContext: + """Add Authorization header with Basic auth. + + Args: + context: Request context. + + Returns: + Context with Authorization header added. + """ + return context.with_header("Authorization", self._auth_header) + + def process_response(self, context: ResponseContext) -> ResponseContext: + """Pass through response unchanged. + + Args: + context: Response context. + + Returns: + Unchanged response context. + """ + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + """No-op error handler. + + Args: + error: The exception that occurred. + context: Request context. + """ + pass diff --git a/src/berapi/middleware/base.py b/src/berapi/middleware/base.py new file mode 100644 index 0000000..3d2b346 --- /dev/null +++ b/src/berapi/middleware/base.py @@ -0,0 +1,134 @@ +"""Middleware protocol and context classes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Protocol, runtime_checkable + +import requests + + +def _utc_now() -> datetime: + """Get current UTC time (timezone-aware).""" + return datetime.now(timezone.utc) + + +@dataclass +class RequestContext: + """Immutable context passed through middleware for requests.""" + + method: str + url: str + headers: dict[str, str] = field(default_factory=dict) + params: dict[str, Any] | None = None + data: Any | None = None + json_body: dict[str, Any] | list[Any] | None = None + timeout: float | None = None + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=_utc_now) + + def with_header(self, key: str, value: str) -> RequestContext: + """Return new context with added header (immutable pattern). + + Args: + key: Header name. + value: Header value. + + Returns: + New RequestContext with the header added. + """ + new_headers = {**self.headers, key: value} + return RequestContext( + method=self.method, + url=self.url, + headers=new_headers, + params=self.params, + data=self.data, + json_body=self.json_body, + timeout=self.timeout, + metadata=self.metadata, + timestamp=self.timestamp, + ) + + def with_metadata(self, key: str, value: Any) -> RequestContext: + """Return new context with added metadata. + + Args: + key: Metadata key. + value: Metadata value. + + Returns: + New RequestContext with the metadata added. + """ + new_metadata = {**self.metadata, key: value} + return RequestContext( + method=self.method, + url=self.url, + headers=self.headers, + params=self.params, + data=self.data, + json_body=self.json_body, + timeout=self.timeout, + metadata=new_metadata, + timestamp=self.timestamp, + ) + + +@dataclass +class ResponseContext: + """Context passed through middleware for responses.""" + + response: requests.Response + request_context: RequestContext + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def elapsed(self) -> float: + """Get response elapsed time in seconds.""" + return self.response.elapsed.total_seconds() + + @property + def status_code(self) -> int: + """Get response status code.""" + return self.response.status_code + + +@runtime_checkable +class Middleware(Protocol): + """Protocol for request/response middleware. + + Middleware can intercept and modify requests before they are sent, + and responses after they are received. + """ + + def process_request(self, context: RequestContext) -> RequestContext: + """Process outgoing request. + + Args: + context: The request context. + + Returns: + Modified request context. + """ + ... + + def process_response(self, context: ResponseContext) -> ResponseContext: + """Process incoming response. + + Args: + context: The response context. + + Returns: + Modified response context. + """ + ... + + def on_error(self, error: Exception, context: RequestContext) -> None: + """Handle errors during request/response cycle. + + Args: + error: The exception that occurred. + context: The request context. + """ + ... diff --git a/src/berapi/middleware/chain.py b/src/berapi/middleware/chain.py new file mode 100644 index 0000000..c9309af --- /dev/null +++ b/src/berapi/middleware/chain.py @@ -0,0 +1,111 @@ +"""Middleware chain executor.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from berapi.logging.setup import get_logger + +if TYPE_CHECKING: + from berapi.middleware.base import Middleware, RequestContext, ResponseContext + + +class MiddlewareChain: + """Executes middleware in order for requests and reverse for responses.""" + + def __init__(self, middlewares: list[Middleware] | None = None) -> None: + """Initialize middleware chain. + + Args: + middlewares: List of middleware to execute. + """ + self._middlewares: list[Middleware] = list(middlewares or []) + self._logger = get_logger("berapi.middleware") + + def add(self, middleware: Middleware) -> MiddlewareChain: + """Add middleware to the chain. + + Args: + middleware: Middleware to add. + + Returns: + Self for chaining. + """ + self._middlewares.append(middleware) + return self + + def insert(self, index: int, middleware: Middleware) -> MiddlewareChain: + """Insert middleware at specific position. + + Args: + index: Position to insert at. + middleware: Middleware to insert. + + Returns: + Self for chaining. + """ + self._middlewares.insert(index, middleware) + return self + + def execute_request(self, context: RequestContext) -> RequestContext: + """Execute all middleware for outgoing request. + + Args: + context: Request context to process. + + Returns: + Processed request context. + """ + for middleware in self._middlewares: + try: + context = middleware.process_request(context) + except Exception as e: + self._logger.error( + "middleware_request_error", + middleware=type(middleware).__name__, + error=str(e), + ) + raise + return context + + def execute_response(self, context: ResponseContext) -> ResponseContext: + """Execute all middleware for incoming response (reverse order). + + Args: + context: Response context to process. + + Returns: + Processed response context. + """ + for middleware in reversed(self._middlewares): + try: + context = middleware.process_response(context) + except Exception as e: + self._logger.error( + "middleware_response_error", + middleware=type(middleware).__name__, + error=str(e), + ) + raise + return context + + def handle_error(self, error: Exception, context: RequestContext) -> None: + """Notify all middleware of an error. + + Args: + error: The exception that occurred. + context: The request context. + """ + for middleware in self._middlewares: + try: + middleware.on_error(error, context) + except Exception as e: + self._logger.warning( + "middleware_error_handler_failed", + middleware=type(middleware).__name__, + error=str(e), + ) + + def __len__(self) -> int: + """Return number of middleware in chain.""" + return len(self._middlewares) diff --git a/src/berapi/middleware/logging.py b/src/berapi/middleware/logging.py new file mode 100644 index 0000000..4b4ebaa --- /dev/null +++ b/src/berapi/middleware/logging.py @@ -0,0 +1,143 @@ +"""Logging middleware for structured request/response logging.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import structlog + +from berapi.middleware.base import RequestContext, ResponseContext +from berapi.utils.curl import generate_curl + +if TYPE_CHECKING: + pass + + +class LoggingMiddleware: + """Structured logging for requests and responses.""" + + def __init__( + self, + logger: structlog.BoundLogger | None = None, + log_request_body: bool = True, + log_response_body: bool = True, + log_headers: bool = True, + redact_headers: frozenset[str] | None = None, + log_curl: bool = True, + max_body_length: int = 10000, + ) -> None: + """Initialize logging middleware. + + Args: + logger: Custom logger. Defaults to berapi.http logger. + log_request_body: Whether to log request bodies. + log_response_body: Whether to log response bodies. + log_headers: Whether to log headers. + redact_headers: Header names to redact (case-insensitive). + log_curl: Whether to log curl commands. + max_body_length: Maximum body length to log (truncates if longer). + """ + self._logger = logger or structlog.get_logger("berapi.http") + self._log_request_body = log_request_body + self._log_response_body = log_response_body + self._log_headers = log_headers + self._redact_headers = redact_headers or frozenset( + {"authorization", "x-api-key", "cookie", "x-auth-token"} + ) + self._log_curl = log_curl + self._max_body_length = max_body_length + + def process_request(self, context: RequestContext) -> RequestContext: + """Log outgoing request. + + Args: + context: Request context. + + Returns: + Unchanged request context. + """ + log_data: dict[str, Any] = { + "event": "http_request", + "method": context.method, + "url": context.url, + } + + if self._log_headers and context.headers: + log_data["headers"] = self._redact(context.headers) + + if self._log_request_body: + if context.json_body: + log_data["body"] = context.json_body + elif context.data: + body_str = str(context.data) + if len(body_str) > self._max_body_length: + body_str = body_str[: self._max_body_length] + "...[truncated]" + log_data["body"] = body_str + + if self._log_curl: + log_data["curl"] = generate_curl(context) + + self._logger.info(**log_data) + return context + + def process_response(self, context: ResponseContext) -> ResponseContext: + """Log incoming response. + + Args: + context: Response context. + + Returns: + Unchanged response context. + """ + log_data: dict[str, Any] = { + "event": "http_response", + "method": context.request_context.method, + "url": context.request_context.url, + "status_code": context.status_code, + "elapsed_seconds": round(context.elapsed, 3), + } + + if self._log_headers: + log_data["headers"] = dict(context.response.headers) + + if self._log_response_body: + try: + log_data["body"] = context.response.json() + except Exception: + body_text = context.response.text + if len(body_text) > self._max_body_length: + body_text = body_text[: self._max_body_length] + "...[truncated]" + if body_text: + log_data["body_text"] = body_text + + self._logger.info(**log_data) + return context + + def on_error(self, error: Exception, context: RequestContext) -> None: + """Log error. + + Args: + error: The exception that occurred. + context: Request context. + """ + self._logger.error( + "http_error", + method=context.method, + url=context.url, + error_type=type(error).__name__, + error_message=str(error), + ) + + def _redact(self, headers: dict[str, str]) -> dict[str, str]: + """Redact sensitive header values. + + Args: + headers: Headers to redact. + + Returns: + Headers with sensitive values replaced. + """ + return { + k: "[REDACTED]" if k.lower() in self._redact_headers else v + for k, v in headers.items() + } diff --git a/src/berapi/py.typed b/src/berapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/berapi/response/__init__.py b/src/berapi/response/__init__.py new file mode 100644 index 0000000..6f013c9 --- /dev/null +++ b/src/berapi/response/__init__.py @@ -0,0 +1,7 @@ +"""Response handling for BerAPI.""" + +from berapi.response.response import Response + +__all__ = [ + "Response", +] diff --git a/src/berapi/response/response.py b/src/berapi/response/response.py new file mode 100644 index 0000000..45490be --- /dev/null +++ b/src/berapi/response/response.py @@ -0,0 +1,533 @@ +"""Response wrapper with fluent assertions.""" + +from __future__ import annotations + +import json +from datetime import timedelta +from typing import Any, Self + +import requests + +from berapi.exceptions.errors import ( + HeaderError, + JsonPathError, + JsonSchemaError, + StatusCodeError, +) +from berapi.utils.json_path import get_by_path, has_path + + +class Response: + """Wrapper around requests.Response with fluent assertion methods. + + All assertion methods return `self` for method chaining. + """ + + def __init__(self, response: requests.Response) -> None: + """Initialize response wrapper. + + Args: + response: The requests Response object to wrap. + """ + self._response = response + self._json_cache: dict[str, Any] | list[Any] | None = None + + # === Properties === + + @property + def status_code(self) -> int: + """Get response status code.""" + return self._response.status_code + + @property + def headers(self) -> requests.structures.CaseInsensitiveDict[str]: + """Get response headers.""" + return self._response.headers + + @property + def text(self) -> str: + """Get response text.""" + return self._response.text + + @property + def content(self) -> bytes: + """Get response content as bytes.""" + return self._response.content + + @property + def json(self) -> dict[str, Any] | list[Any]: + """Get response as JSON. + + Returns: + Parsed JSON response. + + Raises: + ValueError: If response is not valid JSON. + """ + if self._json_cache is None: + try: + self._json_cache = self._response.json() + except json.JSONDecodeError as e: + raise ValueError(f"Response is not valid JSON: {e}") from e + return self._json_cache + + @property + def elapsed(self) -> timedelta: + """Get response elapsed time.""" + return self._response.elapsed + + @property + def url(self) -> str: + """Get request URL.""" + return self._response.url + + @property + def request(self) -> requests.PreparedRequest: + """Get the prepared request.""" + return self._response.request + + @property + def raw_response(self) -> requests.Response: + """Get the underlying requests.Response object.""" + return self._response + + # === Status Code Assertions === + + def assert_status(self, expected: int) -> Self: + """Assert exact status code. + + Args: + expected: Expected status code. + + Returns: + Self for chaining. + + Raises: + StatusCodeError: If status code doesn't match. + """ + if self.status_code != expected: + raise StatusCodeError( + f"Expected status code {expected}, got {self.status_code}", + expected=expected, + actual=self.status_code, + url=self.url, + ) + return self + + def assert_status_range(self, start: int, end: int) -> Self: + """Assert status code is within range (inclusive). + + Args: + start: Start of range (inclusive). + end: End of range (inclusive). + + Returns: + Self for chaining. + + Raises: + StatusCodeError: If status code not in range. + """ + if not (start <= self.status_code <= end): + raise StatusCodeError( + f"Expected status code {start}-{end}, got {self.status_code}", + expected=(start, end), + actual=self.status_code, + url=self.url, + ) + return self + + def assert_2xx(self) -> Self: + """Assert success status code (200-299). + + Returns: + Self for chaining. + """ + return self.assert_status_range(200, 299) + + def assert_3xx(self) -> Self: + """Assert redirect status code (300-399). + + Returns: + Self for chaining. + """ + return self.assert_status_range(300, 399) + + def assert_4xx(self) -> Self: + """Assert client error status code (400-499). + + Returns: + Self for chaining. + """ + return self.assert_status_range(400, 499) + + def assert_5xx(self) -> Self: + """Assert server error status code (500-599). + + Returns: + Self for chaining. + """ + return self.assert_status_range(500, 599) + + # === Header Assertions === + + def assert_header(self, key: str, expected: str) -> Self: + """Assert header has specific value. + + Args: + key: Header name (case-insensitive). + expected: Expected header value. + + Returns: + Self for chaining. + + Raises: + HeaderError: If header doesn't match. + """ + actual = self.headers.get(key) + if actual != expected: + raise HeaderError( + f"Expected header '{key}' to be '{expected}', got '{actual}'", + header=key, + expected=expected, + actual=actual, + url=self.url, + ) + return self + + def assert_header_exists(self, key: str) -> Self: + """Assert header exists. + + Args: + key: Header name (case-insensitive). + + Returns: + Self for chaining. + + Raises: + HeaderError: If header doesn't exist. + """ + if key not in self.headers: + raise HeaderError( + f"Expected header '{key}' to exist", + header=key, + url=self.url, + ) + return self + + def assert_content_type(self, expected: str) -> Self: + """Assert Content-Type header contains value. + + Args: + expected: Expected content type (can be partial match). + + Returns: + Self for chaining. + + Raises: + HeaderError: If Content-Type doesn't contain expected value. + """ + content_type = self.headers.get("Content-Type", "") + if expected not in content_type: + raise HeaderError( + f"Expected Content-Type to contain '{expected}', got '{content_type}'", + header="Content-Type", + expected=expected, + actual=content_type, + url=self.url, + ) + return self + + # === Body Assertions === + + def assert_contains(self, text: str) -> Self: + """Assert response body contains text. + + Args: + text: Text to search for. + + Returns: + Self for chaining. + + Raises: + AssertionError: If text not found. + """ + if text not in self.text: + raise AssertionError(f"Response body does not contain '{text}'") + return self + + def assert_not_contains(self, text: str) -> Self: + """Assert response body does not contain text. + + Args: + text: Text that should not be present. + + Returns: + Self for chaining. + + Raises: + AssertionError: If text found. + """ + if text in self.text: + raise AssertionError(f"Response body contains '{text}' but should not") + return self + + # === JSON Assertions === + + def assert_json_path(self, path: str, expected: Any) -> Self: + """Assert JSON value at path equals expected. + + Supports dot notation for nested access: + - "user.name" -> response["user"]["name"] + - "users.0.name" -> response["users"][0]["name"] + + Args: + path: Dot-separated path to value. + expected: Expected value. + + Returns: + Self for chaining. + + Raises: + JsonPathError: If value doesn't match. + """ + actual = get_by_path(self.json, path) + if actual != expected: + raise JsonPathError( + f"Expected '{path}' to be {expected!r}, got {actual!r}", + path=path, + expected=expected, + actual=actual, + url=self.url, + ) + return self + + def assert_has_key(self, path: str) -> Self: + """Assert JSON has key at path. + + Args: + path: Dot-separated path to check. + + Returns: + Self for chaining. + + Raises: + JsonPathError: If key doesn't exist. + """ + if not has_path(self.json, path): + raise JsonPathError( + f"Expected key '{path}' to exist", + path=path, + expected="", + actual="", + url=self.url, + ) + return self + + def assert_json_not_empty(self, path: str) -> Self: + """Assert JSON value at path is not empty/None. + + Args: + path: Dot-separated path to value. + + Returns: + Self for chaining. + + Raises: + JsonPathError: If value is empty or None. + """ + value = get_by_path(self.json, path) + if value is None or value == "" or value == [] or value == {}: + raise JsonPathError( + f"Expected '{path}' to not be empty, got {value!r}", + path=path, + expected="", + actual=value, + url=self.url, + ) + return self + + def assert_list_not_empty(self) -> Self: + """Assert response is a non-empty JSON array. + + Returns: + Self for chaining. + + Raises: + AssertionError: If not a list or empty. + """ + data = self.json + if not isinstance(data, list): + raise AssertionError( + f"Expected response to be a list, got {type(data).__name__}" + ) + if len(data) == 0: + raise AssertionError("Expected response list to not be empty") + return self + + def assert_json_in(self, path: str, allowed: list[Any]) -> Self: + """Assert JSON value at path is one of allowed values. + + Args: + path: Dot-separated path to value. + allowed: List of allowed values. + + Returns: + Self for chaining. + + Raises: + JsonPathError: If value not in allowed list. + """ + value = get_by_path(self.json, path) + if value not in allowed: + raise JsonPathError( + f"Expected '{path}' to be one of {allowed!r}, got {value!r}", + path=path, + expected=allowed, + actual=value, + url=self.url, + ) + return self + + # === Schema Assertions === + + def assert_json_schema(self, schema: dict[str, Any] | str) -> Self: + """Assert response matches JSON Schema. + + Args: + schema: JSON Schema dict or path to schema file. + + Returns: + Self for chaining. + + Raises: + JsonSchemaError: If validation fails. + """ + from berapi.validation.json_schema import validate_json_schema + + if isinstance(schema, str): + # Load from file + with open(schema) as f: + schema = json.load(f) + + errors = validate_json_schema(self.json, schema) + if errors: + raise JsonSchemaError( + "JSON Schema validation failed", + errors=errors, + url=self.url, + ) + return self + + def assert_json_schema_from_sample(self, sample_path: str) -> Self: + """Assert response matches schema generated from sample JSON. + + Args: + sample_path: Path to sample JSON file. + + Returns: + Self for chaining. + + Raises: + JsonSchemaError: If validation fails. + """ + from berapi.validation.json_schema import validate_against_sample + + errors = validate_against_sample(self.json, sample_path) + if errors: + raise JsonSchemaError( + "Schema validation (from sample) failed", + errors=errors, + url=self.url, + ) + return self + + def assert_openapi(self, operation_id: str, spec_path: str | None = None) -> Self: + """Assert response matches OpenAPI specification. + + Args: + operation_id: Operation ID in the OpenAPI spec. + spec_path: Path to OpenAPI spec file. If None, uses configured path. + + Returns: + Self for chaining. + + Raises: + OpenAPIError: If validation fails. + """ + from berapi.validation.openapi import validate_openapi_response + + errors = validate_openapi_response( + response=self._response, + operation_id=operation_id, + spec_path=spec_path, + ) + if errors: + from berapi.exceptions.errors import OpenAPIError + + raise OpenAPIError( + "OpenAPI validation failed", + errors=errors, + operation_id=operation_id, + url=self.url, + ) + return self + + # === Performance Assertions === + + def assert_response_time(self, max_seconds: float) -> Self: + """Assert response time is under threshold. + + Args: + max_seconds: Maximum allowed response time in seconds. + + Returns: + Self for chaining. + + Raises: + AssertionError: If response time exceeds threshold. + """ + elapsed = self.elapsed.total_seconds() + if elapsed > max_seconds: + raise AssertionError( + f"Response time {elapsed:.3f}s exceeded threshold {max_seconds}s" + ) + return self + + # === Data Access Methods === + + def get(self, path: str, default: Any = None) -> Any: + """Get value from JSON using dot notation. + + Args: + path: Dot-separated path to value. + default: Default value if path not found. + + Returns: + Value at path or default. + """ + return get_by_path(self.json, path, default) + + def get_all(self, paths: list[str]) -> dict[str, Any]: + """Get multiple values from JSON. + + Args: + paths: List of dot-separated paths. + + Returns: + Dict mapping paths to values. + """ + return {path: get_by_path(self.json, path) for path in paths} + + def to_dict(self) -> dict[str, Any] | list[Any]: + """Get response as dictionary/list. + + Returns: + Parsed JSON response. + """ + return self.json + + # === Dunder Methods === + + def __repr__(self) -> str: + """String representation.""" + return f"" diff --git a/src/berapi/utils/__init__.py b/src/berapi/utils/__init__.py new file mode 100644 index 0000000..3e42fe8 --- /dev/null +++ b/src/berapi/utils/__init__.py @@ -0,0 +1,10 @@ +"""Utility functions for BerAPI.""" + +from berapi.utils.curl import generate_curl +from berapi.utils.json_path import get_by_path, has_path + +__all__ = [ + "generate_curl", + "get_by_path", + "has_path", +] diff --git a/src/berapi/utils/curl.py b/src/berapi/utils/curl.py new file mode 100644 index 0000000..776143a --- /dev/null +++ b/src/berapi/utils/curl.py @@ -0,0 +1,59 @@ +"""Curl command generation utilities.""" + +from __future__ import annotations + +import json +import shlex +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from berapi.middleware.base import RequestContext + + +def generate_curl(context: RequestContext) -> str: + """Generate a curl command from a request context. + + Args: + context: Request context to generate curl from. + + Returns: + Curl command string. + """ + parts = ["curl", "-X", context.method] + + # Add URL + url = context.url + if context.params: + param_str = "&".join(f"{k}={v}" for k, v in context.params.items()) + url = f"{url}?{param_str}" + parts.append(shlex.quote(url)) + + # Add headers + for key, value in context.headers.items(): + # Skip internal headers + if key.lower() in ("content-length", "host"): + continue + parts.extend(["-H", shlex.quote(f"{key}: {value}")]) + + # Add body + if context.json_body is not None: + body_str = json.dumps(context.json_body) + parts.extend(["-d", shlex.quote(body_str)]) + elif context.data is not None: + parts.extend(["-d", shlex.quote(str(context.data))]) + + return " ".join(parts) + + +def generate_curl_from_response(response: "RequestContext") -> str: + """Generate curl from a requests Response object. + + This is a compatibility wrapper for the old API. + + Args: + response: Response object with request attribute. + + Returns: + Curl command string. + """ + return generate_curl(response) diff --git a/src/berapi/utils/json_path.py b/src/berapi/utils/json_path.py new file mode 100644 index 0000000..3dd4796 --- /dev/null +++ b/src/berapi/utils/json_path.py @@ -0,0 +1,113 @@ +"""JSON path utilities for dot notation access.""" + +from __future__ import annotations + +from typing import Any + + +def get_by_path(data: dict[str, Any] | list[Any], path: str, default: Any = None) -> Any: + """Get a value from nested data using dot notation. + + Supports both dict and list access: + - "user.name" -> data["user"]["name"] + - "users.0.name" -> data["users"][0]["name"] + - "items.0" -> data["items"][0] + + Args: + data: The data to traverse. + path: Dot-separated path to the value. + default: Default value if path not found. + + Returns: + The value at the path, or default if not found. + + Examples: + >>> data = {"user": {"name": "John", "addresses": [{"city": "NYC"}]}} + >>> get_by_path(data, "user.name") + 'John' + >>> get_by_path(data, "user.addresses.0.city") + 'NYC' + >>> get_by_path(data, "user.missing", "default") + 'default' + """ + if not path: + return data + + current: Any = data + parts = path.split(".") + + for part in parts: + if current is None: + return default + + # Try list index access + if isinstance(current, list): + try: + index = int(part) + if 0 <= index < len(current): + current = current[index] + else: + return default + except ValueError: + return default + # Dict access + elif isinstance(current, dict): + if part in current: + current = current[part] + else: + return default + else: + return default + + return current + + +def has_path(data: dict[str, Any] | list[Any], path: str) -> bool: + """Check if a path exists in nested data. + + Args: + data: The data to check. + path: Dot-separated path to check. + + Returns: + True if the path exists, False otherwise. + + Examples: + >>> data = {"user": {"name": "John"}} + >>> has_path(data, "user.name") + True + >>> has_path(data, "user.email") + False + """ + sentinel = object() + return get_by_path(data, path, sentinel) is not sentinel + + +def set_by_path(data: dict[str, Any], path: str, value: Any) -> None: + """Set a value in nested data using dot notation. + + Creates intermediate dicts as needed. + + Args: + data: The data to modify. + path: Dot-separated path to set. + value: Value to set. + + Examples: + >>> data = {} + >>> set_by_path(data, "user.name", "John") + >>> data + {'user': {'name': 'John'}} + """ + if not path: + return + + parts = path.split(".") + current = data + + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + current[parts[-1]] = value diff --git a/src/berapi/validation/__init__.py b/src/berapi/validation/__init__.py new file mode 100644 index 0000000..8ca55cd --- /dev/null +++ b/src/berapi/validation/__init__.py @@ -0,0 +1,15 @@ +"""Validation utilities for BerAPI.""" + +from berapi.validation.json_schema import ( + validate_json_schema, + validate_against_sample, + generate_schema_from_sample, +) +from berapi.validation.openapi import validate_openapi_response + +__all__ = [ + "validate_json_schema", + "validate_against_sample", + "generate_schema_from_sample", + "validate_openapi_response", +] diff --git a/src/berapi/validation/json_schema.py b/src/berapi/validation/json_schema.py new file mode 100644 index 0000000..e4efba6 --- /dev/null +++ b/src/berapi/validation/json_schema.py @@ -0,0 +1,88 @@ +"""JSON Schema validation utilities.""" + +from __future__ import annotations + +import json +from typing import Any + +import jsonschema +from genson import SchemaBuilder + + +def validate_json_schema( + data: dict[str, Any] | list[Any], + schema: dict[str, Any], +) -> list[str]: + """Validate data against JSON Schema. + + Args: + data: Data to validate. + schema: JSON Schema to validate against. + + Returns: + List of validation error messages. Empty if valid. + """ + errors: list[str] = [] + + try: + jsonschema.validate(data, schema) + except jsonschema.ValidationError as e: + errors.append(str(e.message)) + # Collect all errors using a validator + validator = jsonschema.Draft7Validator(schema) + for error in validator.iter_errors(data): + if error.message not in errors: + path = ".".join(str(p) for p in error.absolute_path) + if path: + errors.append(f"{path}: {error.message}") + else: + errors.append(error.message) + + return errors + + +def generate_schema_from_sample(sample: dict[str, Any] | list[Any]) -> dict[str, Any]: + """Generate JSON Schema from a sample. + + Args: + sample: Sample data to generate schema from. + + Returns: + Generated JSON Schema. + """ + builder = SchemaBuilder() + builder.add_object(sample) + return builder.to_schema() + + +def validate_against_sample( + data: dict[str, Any] | list[Any], + sample_path: str, +) -> list[str]: + """Validate data against schema generated from sample. + + Args: + data: Data to validate. + sample_path: Path to sample JSON file. + + Returns: + List of validation error messages. Empty if valid. + """ + with open(sample_path) as f: + sample = json.load(f) + + schema = generate_schema_from_sample(sample) + return validate_json_schema(data, schema) + + +def load_schema(schema_path: str) -> dict[str, Any]: + """Load JSON Schema from file. + + Args: + schema_path: Path to schema file. + + Returns: + Loaded JSON Schema. + """ + with open(schema_path) as f: + return json.load(f) diff --git a/src/berapi/validation/openapi.py b/src/berapi/validation/openapi.py new file mode 100644 index 0000000..f5086b6 --- /dev/null +++ b/src/berapi/validation/openapi.py @@ -0,0 +1,147 @@ +"""OpenAPI validation utilities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import requests + + +# Cache for loaded specs +_spec_cache: dict[str, Any] = {} + + +def load_openapi_spec(spec_path: str) -> Any: + """Load OpenAPI specification from file. + + Args: + spec_path: Path to OpenAPI spec (YAML or JSON). + + Returns: + Loaded OpenAPI spec. + """ + if spec_path in _spec_cache: + return _spec_cache[spec_path] + + import yaml + + with open(spec_path) as f: + if spec_path.endswith((".yaml", ".yml")): + spec = yaml.safe_load(f) + else: + import json + + spec = json.load(f) + + _spec_cache[spec_path] = spec + return spec + + +def validate_openapi_response( + response: "requests.Response", + operation_id: str, + spec_path: str | None = None, +) -> list[str]: + """Validate response against OpenAPI specification. + + Args: + response: Response to validate. + operation_id: Operation ID in the spec. + spec_path: Path to OpenAPI spec file. + + Returns: + List of validation error messages. Empty if valid. + """ + errors: list[str] = [] + + if spec_path is None: + errors.append("OpenAPI spec path not provided") + return errors + + try: + from openapi_core import OpenAPI + + spec = load_openapi_spec(spec_path) + openapi = OpenAPI.from_dict(spec) + + # Find the operation + operation = None + path_pattern = None + method = response.request.method.lower() if response.request.method else "get" + + for path, path_item in spec.get("paths", {}).items(): + if method in path_item: + op = path_item[method] + if op.get("operationId") == operation_id: + operation = op + path_pattern = path + break + + if operation is None: + errors.append(f"Operation '{operation_id}' not found in spec") + return errors + + # Validate response + status_code = str(response.status_code) + responses = operation.get("responses", {}) + + if status_code not in responses and "default" not in responses: + errors.append( + f"Status code {status_code} not defined for operation '{operation_id}'" + ) + return errors + + response_spec = responses.get(status_code, responses.get("default", {})) + + # Validate content type + content_type = response.headers.get("Content-Type", "") + if "content" in response_spec: + valid_types = list(response_spec["content"].keys()) + if not any(ct in content_type for ct in valid_types): + errors.append( + f"Content-Type '{content_type}' not in allowed types {valid_types}" + ) + + # Validate response body against schema + if "content" in response_spec and "application/json" in response_spec["content"]: + schema = response_spec["content"]["application/json"].get("schema") + if schema: + try: + import jsonschema + + # Resolve $ref if present + schema = _resolve_refs(schema, spec) + jsonschema.validate(response.json(), schema) + except jsonschema.ValidationError as e: + errors.append(f"Response body validation failed: {e.message}") + except Exception as e: + errors.append(f"Response body validation error: {str(e)}") + + except ImportError: + errors.append("openapi-core package not installed") + except Exception as e: + errors.append(f"OpenAPI validation error: {str(e)}") + + return errors + + +def _resolve_refs(schema: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]: + """Resolve $ref references in schema. + + Args: + schema: Schema that may contain $ref. + spec: Full OpenAPI spec for resolving refs. + + Returns: + Schema with refs resolved. + """ + if "$ref" in schema: + ref_path = schema["$ref"] + if ref_path.startswith("#/"): + parts = ref_path[2:].split("/") + resolved = spec + for part in parts: + resolved = resolved.get(part, {}) + return _resolve_refs(resolved, spec) + return schema diff --git a/tests/resources/post_schema.json b/tests/resources/post_schema.json new file mode 100644 index 0000000..00be6ae --- /dev/null +++ b/tests/resources/post_schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["userId", "id", "title", "body"], + "properties": { + "userId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + } + } +} diff --git a/tests/resources/user_sample.json b/tests/resources/user_sample.json new file mode 100644 index 0000000..d8a34ac --- /dev/null +++ b/tests/resources/user_sample.json @@ -0,0 +1,23 @@ +{ + "id": 1, + "name": "Leanne Graham", + "username": "Bret", + "email": "Sincere@april.biz", + "address": { + "street": "Kulas Light", + "suite": "Apt. 556", + "city": "Gwenborough", + "zipcode": "92998-3874", + "geo": { + "lat": "-37.3159", + "lng": "81.1496" + } + }, + "phone": "1-770-736-8031 x56442", + "website": "hildegard.org", + "company": { + "name": "Romaguera-Crona", + "catchPhrase": "Multi-layered client-server neural-net", + "bs": "harness real-time e-markets" + } +} diff --git a/tests/resources/user_schema.json b/tests/resources/user_schema.json new file mode 100644 index 0000000..eeeba95 --- /dev/null +++ b/tests/resources/user_schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "name", "username", "email", "address", "phone", "website", "company"], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "address": { + "type": "object", + "required": ["street", "suite", "city", "zipcode", "geo"], + "properties": { + "street": { "type": "string" }, + "suite": { "type": "string" }, + "city": { "type": "string" }, + "zipcode": { "type": "string" }, + "geo": { + "type": "object", + "properties": { + "lat": { "type": "string" }, + "lng": { "type": "string" } + } + } + } + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + }, + "company": { + "type": "object", + "required": ["name", "catchPhrase", "bs"], + "properties": { + "name": { "type": "string" }, + "catchPhrase": { "type": "string" }, + "bs": { "type": "string" } + } + } + } +} diff --git a/tests/test_crud.py b/tests/test_crud.py index d1befa0..8cb8414 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -1,74 +1,111 @@ -import os +"""Tests demonstrating CRUD operations with berapi.""" import pytest from assertpy import assert_that -from faker import Faker -from berapi.apy import berAPI - -faker = Faker() +from berapi import BerAPI, Settings @pytest.fixture() def api_client(): - return berAPI(base_url='https://gorest.co.in/', - base_headers={'Authorization': "Bearer " + os.getenv('API_TOKEN', 'xxx')}) + """Create API client with base URL configured.""" + return BerAPI(Settings(base_url="https://jsonplaceholder.typicode.com")) -# Executed in sequence top-down class TestCRUD: - a_user = { - "name": faker.name(), - "email": faker.safe_email(), - "gender": "male", - "status": "active" - } - - def test_create_user(self, api_client): + """Test CRUD operations using JSONPlaceholder API. + + Note: JSONPlaceholder is a fake API that simulates CRUD operations. + POST/PUT/PATCH/DELETE requests are faked but return realistic responses. + """ + + def test_create_post(self, api_client): + """Test POST request to create a new resource.""" + payload = { + "title": "Test Post", + "body": "This is a test post body", + "userId": 1 + } + response = (api_client - .post('/public/v1/users', json=self.a_user) - .assert_2xx() - .assert_response_time_less_than(seconds=3) - ) - response_body = response.parse_json() + .post("/posts", json=payload) + .assert_status(201) + .assert_response_time(5)) + + response_body = response.to_dict() + + assert_that(response_body["id"]).is_greater_than(0) + assert_that(response_body["title"]).is_equal_to(payload["title"]) + assert_that(response_body["body"]).is_equal_to(payload["body"]) + assert_that(response_body["userId"]).is_equal_to(payload["userId"]) - self.a_user['id'] = response.get_value('data.id') - # or by regular data access - # self.a_user['id'] = response_body['data']['id'] + def test_read_post(self, api_client): + """Test GET request to read a resource.""" + response = (api_client + .get("/posts/1") + .assert_2xx() + .assert_response_time(5)) - assert_that(response_body['data']['id']).is_greater_than(1) - assert_that(response_body['data']['name']).is_equal_to(self.a_user['name']) - assert_that(response_body['data']['email']).is_equal_to(self.a_user['email']) - assert_that(response_body['data']['gender']).is_equal_to('male') + assert_that(response.get("id")).is_equal_to(1) + assert_that(response.get("userId")).is_equal_to(1) + assert_that(response.get("title")).is_not_empty() - def test_update_user(self, api_client): - print("==> Test 2", self.a_user) + def test_update_post_put(self, api_client): + """Test PUT request to fully update a resource.""" payload = { - "status": "inactive", - "email": "updated+" + self.a_user['email'] + "id": 1, + "title": "Updated Title", + "body": "Updated body content", + "userId": 1 } + response = (api_client - .patch('/public/v1/users/' + str(self.a_user['id']), json=payload) + .put("/posts/1", json=payload) .assert_2xx() - .assert_response_time_less_than(seconds=3) - .parse_json() - ) + .assert_response_time(5) + .to_dict()) - assert_that(response['data']['email']).is_equal_to("updated+" + self.a_user['email']) - assert_that(response['data']['status']).is_equal_to("inactive") + assert_that(response["title"]).is_equal_to("Updated Title") + assert_that(response["body"]).is_equal_to("Updated body content") - def test_delete_user(self, api_client): + def test_update_post_patch(self, api_client): + """Test PATCH request to partially update a resource.""" + payload = {"title": "Patched Title"} + + response = (api_client + .patch("/posts/1", json=payload) + .assert_2xx() + .assert_response_time(5) + .to_dict()) + + assert_that(response["title"]).is_equal_to("Patched Title") + + def test_delete_post(self, api_client): + """Test DELETE request to remove a resource.""" (api_client - .delete('/public/v1/users/' + str(self.a_user['id'])) - .assert_status_code(204) - .assert_response_time_less_than(seconds=3) - ) + .delete("/posts/1") + .assert_2xx() + .assert_response_time(5)) - # re-delete + def test_list_posts(self, api_client): + """Test GET request to list resources.""" response = (api_client - .delete("/public/v1/users/{}".format(self.a_user['id'])) - .assert_status_code(404) - .assert_response_time_less_than(seconds=3) - .parse_json() - ) - assert_that(response['data']['message']).is_equal_to("Resource not found") + .get("/posts") + .assert_2xx() + .assert_list_not_empty() + .assert_response_time(5)) + + posts = response.to_dict() + assert_that(posts).is_length(100) + + def test_filter_posts_by_user(self, api_client): + """Test GET request with query parameters.""" + response = (api_client + .get("/posts", params={"userId": 1}) + .assert_2xx() + .assert_list_not_empty() + .assert_response_time(5)) + + posts = response.to_dict() + for post in posts: + assert_that(post["userId"]).is_equal_to(1) diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..9bfbba1 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,268 @@ +"""Tests showcasing advanced berapi features. + +This module demonstrates the key capabilities of the berapi v2.0 library: +- Settings and configuration +- Middleware system (logging, auth) +- Header assertions +- Custom headers +""" + +from berapi import BerAPI, Settings +from berapi.middleware import LoggingMiddleware, BearerAuthMiddleware, ApiKeyMiddleware + + +# ============================================================================= +# Settings & Configuration +# ============================================================================= + +class TestSettings: + """Test Settings configuration capabilities.""" + + def test_base_url_configuration(self): + """Test API client with base URL setting.""" + api = BerAPI(Settings(base_url="https://jsonplaceholder.typicode.com")) + + # Requests use relative paths + (api.get("/users/1") + .assert_2xx() + .assert_json_path("name", "Leanne Graham")) + + def test_default_headers(self): + """Test API client with default headers.""" + api = BerAPI(Settings( + base_url="https://httpbin.org", + headers={"X-Custom-Header": "test-value"} + )) + + response = api.get("/headers").assert_2xx().to_dict() + assert response["headers"]["X-Custom-Header"] == "test-value" + + def test_timeout_configuration(self): + """Test API client with custom timeout.""" + api = BerAPI(Settings( + base_url="https://httpbin.org", + timeout=10.0 + )) + + (api.get("/delay/1") + .assert_2xx() + .assert_response_time(10)) + + +# ============================================================================= +# Header Assertions +# ============================================================================= + +class TestHeaderAssertions: + """Test header assertion capabilities.""" + + def test_content_type_assertion(self): + """Test content-type header assertion.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx() + .assert_content_type("application/json")) + + def test_header_exists(self): + """Test header exists assertion.""" + (BerAPI() + .get("https://httpbin.org/response-headers?X-Test=value") + .assert_2xx() + .assert_header_exists("X-Test")) + + def test_header_value(self): + """Test header value assertion.""" + (BerAPI() + .get("https://httpbin.org/response-headers?X-Custom=hello") + .assert_2xx() + .assert_header("X-Custom", "hello")) + + +# ============================================================================= +# Middleware System +# ============================================================================= + +class TestMiddleware: + """Test middleware capabilities.""" + + def test_logging_middleware(self): + """Test logging middleware logs requests.""" + api = BerAPI( + Settings(base_url="https://jsonplaceholder.typicode.com"), + middlewares=[LoggingMiddleware()] + ) + + api.get("/users/1").assert_2xx() + + # LoggingMiddleware uses structlog, output depends on configuration + # This test verifies middleware doesn't break the request flow + + def test_bearer_auth_middleware(self): + """Test bearer auth middleware adds Authorization header.""" + api = BerAPI( + Settings(base_url="https://httpbin.org"), + middlewares=[BearerAuthMiddleware(token="test-token-123")] + ) + + response = api.get("/headers").assert_2xx().to_dict() + assert "Bearer test-token-123" in response["headers"]["Authorization"] + + def test_api_key_middleware_header(self): + """Test API key middleware adds X-API-Key header.""" + api = BerAPI( + Settings(base_url="https://httpbin.org"), + middlewares=[ApiKeyMiddleware(api_key="secret-key")] + ) + + response = api.get("/headers").assert_2xx().to_dict() + assert response["headers"]["X-Api-Key"] == "secret-key" + + def test_api_key_middleware_custom_header(self): + """Test API key middleware with custom header name.""" + api = BerAPI( + Settings(base_url="https://httpbin.org"), + middlewares=[ApiKeyMiddleware( + api_key="my-secret", + header_name="X-Custom-Api-Key" + )] + ) + + response = api.get("/headers").assert_2xx().to_dict() + assert response["headers"]["X-Custom-Api-Key"] == "my-secret" + + def test_add_middleware_fluent(self): + """Test adding middleware with fluent API.""" + api = (BerAPI(Settings(base_url="https://httpbin.org")) + .add_middleware(ApiKeyMiddleware(api_key="fluent-key"))) + + response = api.get("/headers").assert_2xx().to_dict() + # httpbin returns X-Api-Key header (default header name for ApiKeyMiddleware) + assert response["headers"].get("X-Api-Key") == "fluent-key" + + +# ============================================================================= +# Data Extraction +# ============================================================================= + +class TestDataExtraction: + """Test data extraction capabilities.""" + + def test_nested_json_path(self): + """Test extracting deeply nested values.""" + response = (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx()) + + # Extract nested values using dot notation + city = response.get("address.city") + lat = response.get("address.geo.lat") + company_name = response.get("company.name") + + assert city == "Gwenborough" + assert lat == "-37.3159" + assert company_name == "Romaguera-Crona" + + def test_list_response(self): + """Test working with list responses.""" + response = (BerAPI() + .get("https://jsonplaceholder.typicode.com/users") + .assert_2xx() + .assert_list_not_empty()) + + # Get the full list and access first user + users = response.to_dict() + assert users[0]["name"] == "Leanne Graham" + + def test_response_properties(self): + """Test accessing response properties.""" + response = (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx()) + + assert response.status_code == 200 + assert "application/json" in response.headers.get("Content-Type", "") + assert response.elapsed.total_seconds() > 0 + + +# ============================================================================= +# Error Handling +# ============================================================================= + +class TestErrorHandling: + """Test error handling and status code assertions.""" + + def test_404_handling(self): + """Test handling 404 responses.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/9999") + .assert_4xx() + .assert_status(404)) + + def test_server_error_assertion(self): + """Test 5xx status code assertion.""" + (BerAPI() + .get("https://httpbin.org/status/500") + .assert_5xx()) + + def test_redirect_handling(self): + """Test 3xx redirect handling.""" + # httpbin follows redirects by default + (BerAPI() + .get("https://httpbin.org/redirect-to?url=https://httpbin.org/get") + .assert_2xx()) + + +# ============================================================================= +# Request Types +# ============================================================================= + +class TestRequestTypes: + """Test different HTTP request types.""" + + def test_post_with_json(self): + """Test POST request with JSON body.""" + payload = {"name": "Test", "value": 123} + + response = (BerAPI() + .post("https://httpbin.org/post", json=payload) + .assert_2xx() + .to_dict()) + + assert response["json"]["name"] == "Test" + assert response["json"]["value"] == 123 + + def test_post_with_form_data(self): + """Test POST request with form data.""" + data = {"field1": "value1", "field2": "value2"} + + response = (BerAPI() + .post("https://httpbin.org/post", data=data) + .assert_2xx() + .to_dict()) + + assert response["form"]["field1"] == "value1" + assert response["form"]["field2"] == "value2" + + def test_request_with_query_params(self): + """Test request with query parameters.""" + params = {"search": "test", "page": 1} + + response = (BerAPI() + .get("https://httpbin.org/get", params=params) + .assert_2xx() + .to_dict()) + + assert response["args"]["search"] == "test" + assert response["args"]["page"] == "1" + + def test_request_with_custom_headers(self): + """Test request with custom headers.""" + headers = {"X-Custom-Header": "custom-value", "Accept-Language": "en-US"} + + response = (BerAPI() + .get("https://httpbin.org/headers", headers=headers) + .assert_2xx() + .to_dict()) + + assert response["headers"].get("X-Custom-Header") == "custom-value" + assert response["headers"].get("Accept-Language") == "en-US" diff --git a/tests/test_schema.py b/tests/test_schema.py index cc3c897..bd93672 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,45 +1,53 @@ +"""Tests demonstrating JSON schema validation capabilities.""" + from pathlib import Path -from berapi.apy import berAPI +from berapi import BerAPI project_path = str(Path(__file__).parent.parent) -def test_schema(): - (berAPI() - .get('https://swapi.dev/api/people/1') +def test_json_schema_validation(): + """Test response validation against JSON schema file.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") .assert_2xx() - .assert_value('name', 'Luke Skywalker') - .assert_response_time_less_than(seconds=5) - .assert_schema(f'{project_path}/tests/resources/sample_schema.json') - ) - + .assert_json_path("name", "Leanne Graham") + .assert_response_time(5) + .assert_json_schema(f"{project_path}/tests/resources/user_schema.json")) -def test_schema_failed(): - (berAPI() - .get('https://swapi.dev/api/people/1') - .assert_2xx() - .assert_value('name', 'Luke Skywalker') - .assert_response_time_less_than(seconds=5) - .assert_schema(f'{project_path}/tests/resources/sample_wrong_schema.json') - ) - -def test_schema_json(): - (berAPI() - .get('https://swapi.dev/api/people/1') +def test_json_schema_from_sample(): + """Test schema generation from sample JSON response.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") .assert_2xx() - .assert_value('name', 'Luke Skywalker') - .assert_response_time_less_than(seconds=5) - .assert_schema_from_sample(f'{project_path}/tests/resources/sample_response.json') - ) + .assert_json_schema_from_sample(f"{project_path}/tests/resources/user_sample.json")) -def test_schema_json_failed(): - (berAPI() - .get('https://swapi.dev/api/people/1') +def test_post_schema_validation(): + """Test post response against schema.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/posts/1") + .assert_2xx() + .assert_json_schema(f"{project_path}/tests/resources/post_schema.json")) + + +def test_inline_schema_validation(): + """Test response validation against inline schema dict.""" + schema = { + "type": "object", + "required": ["id", "name", "email"], + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "phone": {"type": "string"}, + "website": {"type": "string"} + } + } + + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") .assert_2xx() - .assert_value('name', 'Luke Skywalker') - .assert_response_time_less_than(seconds=5) - .assert_schema_from_sample(f'{project_path}/tests/resources/sample_wrong_response.json') - ) + .assert_json_schema(schema)) diff --git a/tests/test_simple.py b/tests/test_simple.py index 9989c7d..453ed5c 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,49 +1,97 @@ -from berapi.apy import berAPI +"""Simple tests demonstrating basic berapi functionality.""" +from berapi import BerAPI -def test_get_user(): - """Test get user""" - (berAPI() + +def test_get_request(): + """Test basic GET request with status assertion.""" + (BerAPI() .get("https://jsonplaceholder.typicode.com/users/1") .assert_2xx()) -def test_get_user_failed(): - """Test Expected to Fail when get user""" - (berAPI() + +def test_json_path_assertion(): + """Test JSON path assertions with dot notation.""" + (BerAPI() .get("https://jsonplaceholder.typicode.com/users/1") - .assert_4xx()) + .assert_2xx() + .assert_json_path("name", "Leanne Graham") + .assert_json_path("address.city", "Gwenborough") + .assert_json_path("company.name", "Romaguera-Crona")) + + +def test_response_contains(): + """Test response body contains specific text.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx() + .assert_contains("Leanne Graham") + .assert_contains("Sincere@april.biz")) + + +def test_response_time(): + """Test response time assertion.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx() + .assert_response_time(5)) + + +def test_get_value(): + """Test extracting values from response.""" + response = (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx()) -def test_starwars_ok(): - url = 'https://swapi.dev/api/people/1' - api = berAPI() - api.get(url).assert_2xx().assert_contains_values(['Luke Skywalker', 'male', 'blue', '172']) - -def test_starwars_failed(): - """Test Expected to Fail when get Luke Skywalker""" - url = 'https://swapi.dev/api/people/1' - api = berAPI() - api.get(url).assert_2xx().assert_contains_values(['Luke Skywalker', 'female', 'red', '172']) - -def test_starwars_values(): - url = 'https://swapi.dev/api/people/1' - api = berAPI() - name = api.get(url).assert_2xx().get_property('name') - assert name == 'Luke Skywalker' - - -def test_starwars_multi_assert(): - url = 'https://swapi.dev/api/people/1' - api = berAPI() - response = api.get(url).assert_2xx().parse_json() - assert response.get('name') == 'Luke Skywalker' - assert response.get('gender') == 'male' - assert response.get('eye_color') == 'red' - assert "this never executed" == "" - -def test_chaining(): - (berAPI() - .get('https://swapi.dev/api/people/1') + name = response.get("name") + email = response.get("email") + city = response.get("address.city") + + assert name == "Leanne Graham" + assert email == "Sincere@april.biz" + assert city == "Gwenborough" + + +def test_to_dict(): + """Test converting response to dictionary.""" + response = (BerAPI() + .get("https://jsonplaceholder.typicode.com/users/1") + .assert_2xx() + .to_dict()) + + assert response["name"] == "Leanne Graham" + assert response["address"]["city"] == "Gwenborough" + + +def test_method_chaining(): + """Test fluent method chaining with multiple assertions.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/posts/1") + .assert_2xx() + .assert_json_path("userId", 1) + .assert_json_path("id", 1) + .assert_has_key("title") + .assert_has_key("body") + .assert_response_time(5)) + + +def test_list_response(): + """Test assertions on list responses.""" + (BerAPI() + .get("https://jsonplaceholder.typicode.com/posts") .assert_2xx() - .assert_value('name', 'Luke Skywalker') - .assert_response_time_less_than(seconds=1) - ) \ No newline at end of file + .assert_list_not_empty()) + + +def test_status_code_assertion(): + """Test specific status code assertion.""" + (BerAPI() + .get("https://httpbin.org/status/200") + .assert_status(200)) + + +def test_404_response(): + """Test 4xx status code assertion.""" + (BerAPI() + .get("https://httpbin.org/status/404") + .assert_4xx())