Metadata-Version: 2.4
Name: api-response-cleaner
Version: 0.1.0
Summary: Turns inconsistent API responses into validated Python data using a developer-defined schema.
Author: Narala Vamsi Krishna
License: MIT
Project-URL: Homepage, https://github.com/VamsiKrishnaNarala/api-response-cleaner
Project-URL: Repository, https://github.com/VamsiKrishnaNarala/api-response-cleaner
Project-URL: Issues, https://github.com/VamsiKrishnaNarala/api-response-cleaner/issues
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# api-response-cleaner

Turns inconsistent API responses into validated Python data using a developer-defined schema. Zero dependencies.

## Why Use This Library? (Use Cases)

- **[Standardize third-party APIs](#example-a--standardizing-apis)**: Normalize inconsistent field types and response formats into a predictable output schema.
- **[Handle legacy and inconsistent data](#example-b--legacy-field-names-and-defaults)**: Support field aliases, optional fields, defaults, and explicit missing/null behavior.
- **Lightweight alternative to Pydantic**: A smaller, standard-library-runtime-dependency-free option for dictionary-based cleaning and validation when you don't need arbitrary object models.
- **[Validate nested JSON safely](#example-c--nested-data)**: Perform nested schema validation and error reporting without manual chains of `.get()` calls.

## Installation

```bash
pip install api-response-cleaner
```

## Quick Start

```python
from api_response_cleaner import clean

result = clean(
    {"full_name": " Ravi ", "age": "25"},
    schema={"name": {"type": str, "required": True},
            "age": {"type": int, "required": True}},
    aliases={"name": ["full_name"]},
)
print(result.data)    # {"name": "Ravi", "age": 25}
print(result.errors)  # []
```

## Schema Reference

Define fields as dictionaries. Supported keys:
- `type`: `str`, `int`, `float`, `bool`, `list`, `dict`, `object`
- `required`: boolean, default `False`
- `default`: fallback value if key absent
- `nullable`: boolean, default `False`
- `aliases`: list of alternative keys
- `strip`: boolean, default `True` (for strings)
- `choices`: iterable of allowed values
- `min`, `max`: bounds for `int` and `float`
- `min_length`, `max_length`: length bounds for `str` and `list`
- `schema`: dictionary of sub-fields (implies `type` dict)
- `items`: type or spec for list elements

Shorthand: `{"age": int}` is equivalent to `{"age": {"type": int}}`.

## Missing and Null Decision Table

| Input state                 | optional       | required              | default=7 | nullable=True         |
|-----------------------------|----------------|-----------------------|-----------|-----------------------|
| key absent                  | key omitted    | missing_required      | 7         | key omitted           |
| key present, value null     | key omitted    | null_not_allowed      | 7         | None (null preserved) |
| key present, value "25"     | 25             | 25                    | 25        | 25                    |
| key present, value ""       | conversion_failed in all four columns                                      |

Note: `nullable=True` + `required=True` with key absent gives `missing_required`.

## Alias and Nested Rules

- Aliases are tried in order if canonical name is missing. 
- If canonical name and alias are both present and non-null, canonical wins.
- Alias keys are consumed; they are not reported as extra or unexpected.
- Errors are reported on canonical paths.
- Nested schema values must be objects (dict).
- Inner errors are collected and reported with full dotted paths (e.g. `address.city`).
- If an inner error occurs, the whole nested key is omitted from result data.

## Structured Defaults Warning

A structured default (`list` or `dict`) is **trusted**. It is returned as-is (deep copied) and its contents are NOT normalized, constraint-checked, or mapped for aliases. Structured defaults must already be in their final valid form.

## Constraints

| Constraint                | Supported Types         |
|---------------------------|-------------------------|
| `min`, `max`              | `int`, `float`          |
| `min_length`, `max_length`| `str`, `list`           |
| `choices`                 | `str`, `int`, `float`, `bool`, `object` |

## Conversion Rules

- `str`: Accepts strings, ints, finite floats. Strips whitespace by default.
- `int`: Accepts ints, whole floats (e.g., `25.0`), and numeric strings.
- `float`: Accepts finite floats, ints, and numeric strings.
- `bool`: Accepts booleans, ints `0` and `1`, strings `true`, `false`, `yes`, `no`, `on`, `off`, `1`, `0`.
- `list`: Accepts lists and tuples.
- `dict`: Accepts dicts.
- `object`: Accepts anything.

## Error Codes

- `missing_required`
- `null_not_allowed`
- `conversion_failed`
- `not_in_choices`
- `too_small`
- `too_large`
- `too_short`
- `too_long`
- `unexpected_field`
- `invalid_input`

## Examples

### Example A — Standardizing APIs

Normalize inconsistent payloads into a predictable shape.

```python
from api_response_cleaner import clean

schema = {
    "name": {"type": str, "aliases": ["full_name"], "required": True},
    "age": {"type": int, "nullable": True}
}

api_a = {"name": "Ravi", "age": 25}
api_b = {"name": "Ravi", "age": "25"}
api_c = {"full_name": "Ravi", "age": None}

for api, payload in zip(["A", "B", "C"], [api_a, api_b, api_c]):
    res = clean(payload, schema)
    print(f"API {api}: data={res.data}, errors={res.errors}")
```

*Note that API C's `age=None` is handled exactly according to the schema (preserved because `nullable=True`). It is never silently converted to a zero value or a default unless explicitly configured.*

### Example B — Legacy field names and defaults

Define canonical fields while supporting old aliases and providing sensible defaults.

```python
from api_response_cleaner import clean

schema = {
    "theme": {"type": str, "aliases": ["ui_theme", "color_mode"], "default": "light"}
}

# 1. Canonical key works normally
print(clean({"theme": "dark"}, schema).data) 
# -> {"theme": "dark"}

# 2. Alias is seamlessly mapped to the canonical key
print(clean({"ui_theme": "dark"}, schema).data) 
# -> {"theme": "dark"}

# 3. Missing key falls back to the default
print(clean({}, schema).data) 
# -> {"theme": "light"}

# 4. Explicit null ignores the default unless the field is nullable
res = clean({"theme": None}, schema)
print(res.errors[0].code) 
# -> ErrorCode.null_not_allowed
```

### Example C — Nested data

Safely parse nested user/address data without manual `.get()` chains. Invalid nested objects are omitted entirely instead of returned partially cleaned.

```python
from api_response_cleaner import Cleaner

cleaner = Cleaner({
    "address": {
        "schema": {
            "city": {"type": str, "required": True},
            "zip": int
        }
    }
})

# Valid payload
res_valid = cleaner.clean({"address": {"city": "NY", "zip": "10001"}})
print(res_valid.data)
# -> {"address": {"city": "NY", "zip": 10001}}

# Invalid payload
res_invalid = cleaner.clean({"address": {"zip": "abc"}})
print("Errors:", [(e.field, e.code.value) for e in res_invalid.errors])
# -> Errors: [('address.city', 'missing_required'), ('address.zip', 'conversion_failed')]
print("Data:", res_invalid.data)
# -> Data: {}
```

### Example D — List validation

Validate lists of typed items. Bad elements are reported by their original index, and the whole list is omitted from data if any element is invalid.

```python
from api_response_cleaner import clean

schema = {
    "scores": {"type": list, "items": int}
}

res = clean({"scores": ["1", "x", None, "3"]}, schema)
print("Errors:", [(e.field, e.code.value) for e in res.errors])
# -> Errors: [('scores[1]', 'conversion_failed'), ('scores[2]', 'null_not_allowed')]
print("Data:", res.data)
# -> Data: {}
```

## Development Commands

```bash
pip install -e .[dev]
pytest
python -m build
twine check dist/*
```

## Releasing

- [ ] pytest passes on Python 3.11 and the newest installed Python
- [ ] python -m build produces one sdist and one wheel
- [ ] twine check dist/* passes
- [ ] the wheel installs into a clean virtual environment and the quick-start example runs
- [ ] [project.urls], author and license holder are filled in
- [ ] the package name is confirmed available on PyPI, then upload to TestPyPI first
