Metadata-Version: 2.4
Name: scrippy-api
Version: 2.0.0
Summary: "Fonctions d'utilisation d'API ReST."
Home-page: https://codeberg.org/scrippy/scrippy-api
Author: Michael Costa, Florent Chevalier
Author-email: michael.costa@mcos.nc, florent.chevalier.nc@gmail.com
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Environment :: Console
Classifier: Development Status :: 5 - Production/Stable
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.21.0
Requires-Dist: PyYAML>=5.3.1
Requires-Dist: openapi-core<0.24,>=0.23
Provides-Extra: dev
Requires-Dist: twine; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: wheel>=0.37.0; extra == "dev"
Requires-Dist: flake8>=4.0.1; extra == "dev"
Requires-Dist: flake8-breakpoint>=1.1.0; extra == "dev"
Requires-Dist: flake8-builtins>=1.5.3; extra == "dev"
Requires-Dist: flake8-print>=4.0.0; extra == "dev"
Requires-Dist: flake8-return>=1.1.3; extra == "dev"
Requires-Dist: pep8-naming>=0.8.2; extra == "dev"
Requires-Dist: pytest>=6.2.2; extra == "dev"
Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
Requires-Dist: bump2version>=1.0.1; extra == "dev"
Requires-Dist: setuptools>=60.9.2; extra == "dev"
Requires-Dist: pypirc-chappers>=1.0.8; extra == "dev"
Requires-Dist: pylint>=2.12.2; extra == "dev"
Dynamic: license-file

![Build Status](https://drone-ext.mcos.nc/api/badges/scrippy/scrippy-api/status.svg) ![License](https://img.shields.io/static/v1?label=license&color=orange&message=MIT) ![Language](https://img.shields.io/static/v1?label=language&color=informational&message=Python)

![Scrippy, my scrangourou friend](./scrippy-api.png "Scrippy, my scrangourou friend")

# `scrippy_api`

REST API client for the `Scrippy` framework (https://codeberg.org/scrippy).

## Requirements

- Python 3.11 or later

### Python modules

The modules listed below will be automatically installed.

- requests
- PyYAML
- openapi-core

## Installation

### Manual

```bash
git clone https://codeberg.org/scrippy/scrippy-api.git
cd scrippy-api
python -m pip install -r requirements.txt
make install
```

### With `pip`

```bash
pip install scrippy-api
```

## Usage

The `scrippy_api.api` module provides:

- the `Client` object, which queries any [_REST API_](https://en.wikipedia.org/wiki/Representational_state_transfer) uniformly:
  - `Client.request()`: executes an HTTP request,
  - `Client.download()`: downloads a file,
  - `Client.load_openapi()` and `Client.call()`: load an [OpenAPI](https://www.openapis.org/) specification, then call its operations with validation of each request (see [OpenAPI](#openapi)),
- the `OpenApi` object, which loads an OpenAPI specification, builds operation URLs and validates requests.

`Client.request()` returns a `requests.Response` object (see the [documentation](https://requests.readthedocs.io/en/latest/user/advanced/#request-and-response-objects)) that must be handled by the script.

Both parameter keys and values are automatically encoded when necessary.

### Error handling

In case of error (HTTP code >= 400, network error, timeout, refused redirection...), the client raises a `scrippy_api.ScrippyApiError` exception. When the server responded, the `requests.Response` object is available in the `response` attribute of the exception (`None` otherwise):

```python
from scrippy_api import ScrippyApiError
from scrippy_api.api import Client
client = Client()
try:
  response = client.request(method="GET", url="https://montypython.org/user")
except ScrippyApiError as err:
  if err.response is not None and err.response.status_code == 404:
    print("Unknown user")
```

This behavior can be inhibited during the client instantiation by setting the `exit_on_error` parameter to `False`:

```python
from scrippy_api.api import Client
client = Client(exit_on_error=False)
```

In this case, any encountered errors will appear in the log file as `warning` and `Client.request()` returns:
- the `requests.Response` object if the server responded, even with an HTTP error code,
- `None` if no response was received (network error, timeout...).

A `requests.Response` object with an HTTP error code evaluates to `False`, so both cases can be handled at once:

```python
response = client.request(method="GET", url="https://montypython.org/user")
if not response:
  print("No response or HTTP error")
```

Whatever the value of `exit_on_error`, a `ScrippyApiError` exception is always raised, and nothing is sent, when:
- the HTTP method is not supported,
- the request is not valid against the loaded OpenAPI specification.

### SSL certificate verification

The verification of the remote server SSL certificate can be disabled by passing the optional `verify` parameter of the `Client` object to `False`:

```python
from scrippy_api.api import Client
client = Client(verify=False)
```

**Note**: Disabling certificate verification is discouraged because it presents real security risks.

### Parameters

Parameters of the `Client.request()` method:

| Parameter | Type | Description | Default value |
| --------- | ---- | ----------- | ------------- |
| `method` | String | HTTP method (see [HTTP methods](#http-methods)). Mandatory | |
| `url` | String | URL to reach. Mandatory | |
| `params` | Dictionary | Query parameters, concatenated to the URL | `None` |
| `data` | Dictionary, string or bytes | Data to be sent in the request body (form encoded when a dictionary) | `None` |
| `json` | Any JSON serializable object | Data to be sent in the request body in _JSON_ format. Not to be used with `data` or `files` | `None` |
| `files` | Dictionary | Files to be uploaded in _multipart_, in the form `{<field name>: <file>}` or `{<field name>: (<file name>, <file>)}` | `None` |
| `headers` | Dictionary | Headers to be sent with the request | `None` |
| `cookies` | Dictionary | Cookies to be sent with the request | `None` |
| `auth` | Tuple | Username and password for _BASIC AUTH_ authentication | `None` |
| `timeout` | Number | Maximum waiting time in seconds to connect and between two received data packets (`None`: no limit) | `300` |
| `proxies` | Dictionary | Proxy servers by protocol, e.g. `{"https": "http://proxy:3128"}` | `None` |
| `allow_redirects` | Boolean | Follow redirections (see [Redirections](#redirections)) | `None` |
| `validate` | Boolean | Validate the request against the loaded OpenAPI specification (see [OpenAPI](#openapi)) | `None` |

### HTTP methods

| HTTP method | Description |
| ----------- | ----------- |
| `GET` | Retrieve a resource or list of resource URIs |
| `POST` | Create a resource |
| `PUT` | Replace or create a resource |
| `PATCH` | Partially update a resource |
| `DELETE` | Delete a resource |
| `HEAD` | Retrieve the headers of a resource, without its content |
| `OPTIONS` | Retrieve the communication options of a resource (e.g. allowed methods) |
| `TRACE` | Diagnostic: the server sends back the request as received (rarely enabled) |

Method names are case sensitive (`"GET"`, not `"get"`).

### Redirections

By default (`allow_redirects=None`):
- `GET` follows redirections,
- `HEAD` and `TRACE` do not follow redirections,
- `POST`, `PUT`, `PATCH`, `DELETE` and `OPTIONS` only follow `307` and `308` redirections, which preserve method and body. Any other redirection (`301`, `302`, `303`) is considered as an error (`Redirection on <METHOD>`), since following it would silently turn the request into a `GET` or drop its body.

`allow_redirects=True` follows all redirections (`requests` behavior), `allow_redirects=False` follows none.

### Examples

#### URL with parameters

```python
from scrippy_api.api import Client
params = {"name": "Luiggi Vercotti", "password": "dead/parrot"}
client = Client()
response = client.request(method="GET", url="https://montypython.org/user", params=params)
```

The called URL will be:
```
https://montypython.org/user?name=Luiggi+Vercotti&password=dead%2Fparrot
```

#### Basic authentication (BASIC AUTH)

```python
from scrippy_api.api import Client
auth = ("Luiggi Vercotti", "dead/parrot")
client = Client()
response = client.request(method="POST", url="https://montypython.org", auth=auth)
```

#### Sending data

Creating the `Luiggi Vercotti` user with the password `dead/parrot`:

```python
from scrippy_api.api import Client
data = {"name": "Luiggi Vercotti", "password": "dead/parrot"}
client = Client()
response = client.request(method="POST", url="https://montypython.org/user", data=data)
```

Same request with a _JSON_ body:

```python
response = client.request(method="POST", url="https://montypython.org/user", json=data)
```

#### File upload

Upload of the two files `./images/dead_parrot.png` and `./images/flying_circus.mp4`:

```python
from scrippy_api.api import Client
client = Client()
with open("./images/dead_parrot.png", "rb") as image, open("./images/flying_circus.mp4", "rb") as video:
  files = {"image": ("dead_parrot.png", image), "video": ("flying_circus.mp4", video)}
  response = client.request(method="POST", url="https://montypython.org/upload", files=files)
```

#### Resource modification

Replacing the password of the `Luiggi Vercotti` user:

```python
from scrippy_api.api import Client
auth = ("Luiggi Vercotti", "dead/parrot")
data = {"password": "live/parrot"}
params = {"name": "Luiggi Vercotti"}
client = Client()
response = client.request(method="PATCH",
                          url="https://montypython.org/user",
                          params=params,
                          data=data,
                          auth=auth)
```

### File download

`Client.download()` saves the file in the `local_path` directory, under the `filename` name (by default, the last part of the URL). It accepts the `timeout`, `params`, `headers`, `cookies`, `proxies` and `auth` parameters of `Client.request()`.

It returns `True` on success. On error, it raises a `ScrippyApiError` exception, or returns `False` when `exit_on_error` is `False`.

```python
from scrippy_api.api import Client
url = "https://montypython.org/inquisition.zip"
local_dir = "/home/luiggi.vercotti"
local_filename = "spanish_inquisition.zip"
client = Client()
if client.download(url, local_dir, local_filename):
  print("No one expects the Spanish inquisition")
```

### OpenAPI

The client can load an [OpenAPI](https://www.openapis.org/) 3.x specification (JSON or YAML) and validate each request against it **before** sending it.

**Note**: Swagger 2.0 specifications are not supported. Convert them to OpenAPI 3 first, with https://converter.swagger.io or `npx swagger2openapi`.

#### Loading a specification

```python
from scrippy_api.api import Client
client = Client()
# From an URL: relative server URLs are resolved against the specification URL
client.load_openapi("https://montypython.org/api/openapi.json")
# From a file: base_url is needed only if the specification server URLs are relative
client.load_openapi("./openapi.yaml", base_url="https://montypython.org/")
```

A specification can also be given at instantiation: `Client(openapi=OpenApi.from_file("./openapi.yaml"))`.

#### Calling an operation

`Client.call()` executes an operation of the specification, identified by its `operationId` (or by `"<METHOD> <path>"` for operations without `operationId`). The HTTP method and the URL come from the specification, path parameters are given with `path_params` and any other `Client.request()` parameter can be used:

```python
response = client.call("updateUser", path_params={"user": "luiggi"}, json={"password": "live/parrot"})
```

#### Validation

A request is validated when a specification is loaded and its URL belongs to one of the specification servers, whether it is sent with `Client.call()` or `Client.request()`. The `validate` parameter forces (`True`) or disables (`False`) the validation.

An invalid request is never sent, and a `ScrippyApiError` exception is raised whatever the value of `exit_on_error`:

```python
from scrippy_api import ScrippyApiError
try:
  client.call("updateUser", path_params={"user": "luiggi"}, json={"password": 42})
except ScrippyApiError as err:
  print(err)
  # Invalid request: PUT https://montypython.org/api/user/luiggi: Invalid request body: password: 42 is not of type 'string'
```

To avoid leaking secrets in logs, the error message never contains the request body nor string values.

Requests followed after a `307`/`308` redirection and `Client.download()` requests are not validated.

#### The `OpenApi` object

The `OpenApi` object can also be used on its own:

```python
from scrippy_api.api import OpenApi
api = OpenApi.from_file("./openapi.yaml", base_url="https://montypython.org/")
api.get_operation("updateUser")
# {'method': 'PUT', 'path': '/user/{user}', 'servers': ['https://montypython.org/api']}
api.build_url("updateUser", {"user": "luiggi"})
# 'https://montypython.org/api/user/luiggi'
api.matches("https://montypython.org/api/user/luiggi")
# True
```

`OpenApi.validate()` validates a `requests.PreparedRequest` and raises a `ScrippyApiError` exception if it is not valid.

