Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- `datacontract test` now supports testing HTTP APIs.


## [0.10.33] - 2025-07-29

### Added
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ Supported server types:
- [kafka](#kafka)
- [postgres](#postgres)
- [trino](#trino)
- [api](#api)
- [local](#local)

Supported formats:
Expand Down Expand Up @@ -802,6 +803,38 @@ models:
| `DATACONTRACT_TRINO_PASSWORD` | `mysecretpassword` | Password |


#### API

Data Contract CLI can test APIs that return data in JSON format.
Currently, only GET requests are supported.

##### Example

datacontract.yaml
```yaml
servers:
api:
type: "api"
location: "https://api.example.com/path"
delimiter: none # new_line, array, or none (default)

models:
my_object: # corresponds to the root element of the JSON response
type: object
fields:
field1:
type: string
fields2:
type: number
```

##### Environment Variables

| Environment Variable | Example | Description |
|-----------------------------------------|------------------|---------------------------------------------------|
| `DATACONTRACT_API_HEADER_AUTHORIZATION` | `Bearer <token>` | The value for the `authorization` header. Optional. |


#### Local

Data Contract CLI can test local files in parquet, json, csv, or delta format.
Expand Down
37 changes: 37 additions & 0 deletions datacontract/engines/data_contract_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import atexit
import os
import tempfile
import typing

import requests
from duckdb.duckdb import DuckDBPyConnection

from datacontract.engines.data_contract_checks import create_checks
Expand Down Expand Up @@ -46,6 +50,9 @@ def execute_data_contract_test(
run.outputPortId = server.outputPortId
run.server = server_name

if server.type == "api":
server = process_api_response(run, server)

run.checks.extend(create_checks(data_contract_specification, server))

# TODO check server is supported type for nicer error messages
Expand Down Expand Up @@ -74,3 +81,33 @@ def get_server(data_contract_specification: DataContractSpecification, server_na
server_name = list(data_contract_specification.servers.keys())[0]
server = data_contract_specification.servers.get(server_name)
return server


def process_api_response(run, server):
tmp_dir = tempfile.TemporaryDirectory(prefix="datacontract_cli_api_")
atexit.register(tmp_dir.cleanup)
headers = {}
if os.getenv("DATACONTRACT_API_HEADER_AUTHORIZATION") is not None:
headers["Authorization"] = os.getenv("DATACONTRACT_API_HEADER_AUTHORIZATION")
try:
response = requests.get(server.location, headers=headers)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise DataContractException(
type="connection",
name="API server connection error",
result=ResultEnum.error,
reason=f"Failed to fetch API response from {server.location}: {e}",
engine="datacontract",
)
with open(f"{tmp_dir.name}/api_response.json", "w") as f:
f.write(response.text)
run.log_info(f"Saved API response to {tmp_dir.name}/api_response.json")
server = Server(
type="local",
format="json",
path=f"{tmp_dir.name}/api_response.json",
dataProductId=server.dataProductId,
outputPortId=server.outputPortId,
)
return server
8 changes: 8 additions & 0 deletions datacontract/engines/fastjsonschema/check_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ def process_json_file(run, schema, model_name, validate, file, delimiter):

def process_local_file(run, server, schema, model_name, validate):
path = server.path
if not path:
raise DataContractException(
type="schema",
name="Check that JSON has valid schema",
result=ResultEnum.warning,
reason="For server with type 'local', a 'path' must be defined.",
engine="datacontract",
)
if "{model}" in path:
path = path.format(model=model_name)

Expand Down
3 changes: 3 additions & 0 deletions datacontract/engines/soda/connections/duckdb_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def get_duckdb_connection(
elif server.format == "delta":
con.sql("update extensions;") # Make sure we have the latest delta extension
con.sql(f"""CREATE VIEW "{model_name}" AS SELECT * FROM delta_scan('{model_path}');""")
table_info = con.sql(f"PRAGMA table_info('{model_name}');").fetchdf()
if table_info is not None and not table_info.empty:
run.log_info(f"DuckDB Table Info: {table_info.to_string(index=False)}")
return con


Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/api/request.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=temperature_2m,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m
202 changes: 202 additions & 0 deletions tests/fixtures/api/response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
{
"latitude": 52.52,
"longitude": 13.419998,
"generationtime_ms": 0.141263008117676,
"utc_offset_seconds": 0,
"timezone": "GMT",
"timezone_abbreviation": "GMT",
"elevation": 38,
"current_units": {
"time": "iso8601",
"interval": "seconds",
"temperature_2m": "°C",
"wind_speed_10m": "km/h"
},
"current": {
"time": "2025-08-01T11:15",
"interval": 900,
"temperature_2m": 19.1,
"wind_speed_10m": 11.1
},
"hourly_units": {
"time": "iso8601",
"temperature_2m": "°C",
"relative_humidity_2m": "%",
"wind_speed_10m": "km/h"
},
"hourly": {
"time": [
"2025-08-01T00:00",
"2025-08-01T01:00",
"2025-08-01T02:00",
"2025-08-01T03:00",
"2025-08-01T04:00",
"2025-08-01T05:00",
"2025-08-01T06:00",
"2025-08-01T07:00",
"2025-08-01T08:00",
"2025-08-01T09:00",
"2025-08-01T10:00",
"2025-08-01T11:00",
"2025-08-01T12:00",
"2025-08-01T13:00",
"2025-08-01T14:00",
"2025-08-01T15:00",
"2025-08-01T16:00",
"2025-08-01T17:00",
"2025-08-01T18:00",
"2025-08-01T19:00",
"2025-08-01T20:00",
"2025-08-01T21:00",
"2025-08-01T22:00",
"2025-08-01T23:00",
"2025-08-02T00:00",
"2025-08-02T01:00",
"2025-08-02T02:00",
"2025-08-02T03:00",
"2025-08-02T04:00",
"2025-08-02T05:00",
"2025-08-02T06:00",
"2025-08-02T07:00",
"2025-08-02T08:00",
"2025-08-02T09:00",
"2025-08-02T10:00",
"2025-08-02T11:00",
"2025-08-02T12:00",
"2025-08-02T13:00",
"2025-08-02T14:00",
"2025-08-02T15:00",
"2025-08-02T16:00",
"2025-08-02T17:00",
"2025-08-02T18:00",
"2025-08-02T19:00",
"2025-08-02T20:00",
"2025-08-02T21:00",
"2025-08-02T22:00",
"2025-08-02T23:00",
"2025-08-03T00:00",
"2025-08-03T01:00",
"2025-08-03T02:00",
"2025-08-03T03:00",
"2025-08-03T04:00",
"2025-08-03T05:00",
"2025-08-03T06:00",
"2025-08-03T07:00",
"2025-08-03T08:00",
"2025-08-03T09:00",
"2025-08-03T10:00",
"2025-08-03T11:00",
"2025-08-03T12:00",
"2025-08-03T13:00",
"2025-08-03T14:00",
"2025-08-03T15:00",
"2025-08-03T16:00",
"2025-08-03T17:00",
"2025-08-03T18:00",
"2025-08-03T19:00",
"2025-08-03T20:00",
"2025-08-03T21:00",
"2025-08-03T22:00",
"2025-08-03T23:00",
"2025-08-04T00:00",
"2025-08-04T01:00",
"2025-08-04T02:00",
"2025-08-04T03:00",
"2025-08-04T04:00",
"2025-08-04T05:00",
"2025-08-04T06:00",
"2025-08-04T07:00",
"2025-08-04T08:00",
"2025-08-04T09:00",
"2025-08-04T10:00",
"2025-08-04T11:00",
"2025-08-04T12:00",
"2025-08-04T13:00",
"2025-08-04T14:00",
"2025-08-04T15:00",
"2025-08-04T16:00",
"2025-08-04T17:00",
"2025-08-04T18:00",
"2025-08-04T19:00",
"2025-08-04T20:00",
"2025-08-04T21:00",
"2025-08-04T22:00",
"2025-08-04T23:00",
"2025-08-05T00:00",
"2025-08-05T01:00",
"2025-08-05T02:00",
"2025-08-05T03:00",
"2025-08-05T04:00",
"2025-08-05T05:00",
"2025-08-05T06:00",
"2025-08-05T07:00",
"2025-08-05T08:00",
"2025-08-05T09:00",
"2025-08-05T10:00",
"2025-08-05T11:00",
"2025-08-05T12:00",
"2025-08-05T13:00",
"2025-08-05T14:00",
"2025-08-05T15:00",
"2025-08-05T16:00",
"2025-08-05T17:00",
"2025-08-05T18:00",
"2025-08-05T19:00",
"2025-08-05T20:00",
"2025-08-05T21:00",
"2025-08-05T22:00",
"2025-08-05T23:00",
"2025-08-06T00:00",
"2025-08-06T01:00",
"2025-08-06T02:00",
"2025-08-06T03:00",
"2025-08-06T04:00",
"2025-08-06T05:00",
"2025-08-06T06:00",
"2025-08-06T07:00",
"2025-08-06T08:00",
"2025-08-06T09:00",
"2025-08-06T10:00",
"2025-08-06T11:00",
"2025-08-06T12:00",
"2025-08-06T13:00",
"2025-08-06T14:00",
"2025-08-06T15:00",
"2025-08-06T16:00",
"2025-08-06T17:00",
"2025-08-06T18:00",
"2025-08-06T19:00",
"2025-08-06T20:00",
"2025-08-06T21:00",
"2025-08-06T22:00",
"2025-08-06T23:00",
"2025-08-07T00:00",
"2025-08-07T01:00",
"2025-08-07T02:00",
"2025-08-07T03:00",
"2025-08-07T04:00",
"2025-08-07T05:00",
"2025-08-07T06:00",
"2025-08-07T07:00",
"2025-08-07T08:00",
"2025-08-07T09:00",
"2025-08-07T10:00",
"2025-08-07T11:00",
"2025-08-07T12:00",
"2025-08-07T13:00",
"2025-08-07T14:00",
"2025-08-07T15:00",
"2025-08-07T16:00",
"2025-08-07T17:00",
"2025-08-07T18:00",
"2025-08-07T19:00",
"2025-08-07T20:00",
"2025-08-07T21:00",
"2025-08-07T22:00",
"2025-08-07T23:00"
],
"temperature_2m": [16.3, 16.2, 16.2, 15.8, 15.6, 15.6, 16.2, 16.9, 17.6, 18.2, 18.9, 18.8, 20.1, 19.5, 19.4, 19.6, 19.9, 19.9, 19.8, 19.3, 18.8, 18, 17.6, 16.8, 16.3, 16.1, 15.9, 15.6, 15.4, 15.5, 16, 17.1, 17.7, 18.6, 19.4, 20.3, 20.9, 21.6, 22.2, 22.2, 22, 22, 21.6, 20.9, 20.2, 19.3, 18.8, 18, 17.5, 17.1, 16.4, 15.8, 15.3, 15.2, 16.2, 17, 17.5, 17.6, 18.1, 18.9, 19.8, 20.3, 20.1, 19.9, 19.3, 18.9, 18.5, 17.7, 17.2, 16.6, 16.2, 15.9, 15.7, 15.5, 15.4, 15.2, 15.1, 15.3, 15.6, 15.8, 16.2, 17, 18.6, 20.5, 22, 22.9, 23.4, 23.6, 23.4, 22.9, 22.2, 21.3, 20.3, 19.4, 18.9, 18.6, 18.3, 17.9, 17.6, 17.3, 17, 16.7, 16.8, 17.7, 19.1, 20.1, 20.6, 20.8, 21, 21.5, 22, 22.2, 21.8, 21, 20.1, 19.1, 18, 17, 16.1, 15.4, 14.9, 14.4, 14, 13.8, 13.8, 14, 14.6, 15.6, 17, 18.3, 19.5, 20.7, 21.4, 21.6, 21.4, 21, 20.6, 20.1, 19.5, 18.6, 17.5, 16.6, 15.8, 15.1, 14.5, 13.8, 13.3, 13, 13.2, 13.7, 14.6, 16, 17.8, 19.5, 20.9, 22.2, 23.3, 24, 24.4, 24.6, 24.4, 23.9, 23.2, 22.2, 20.9, 19.8, 19.3, 19.1],
"relative_humidity_2m": [81, 83, 88, 90, 91, 90, 86, 81, 77, 74, 68, 75, 65, 67, 66, 67, 61, 64, 64, 68, 74, 77, 77, 83, 87, 90, 90, 91, 91, 90, 86, 75, 73, 67, 63, 58, 55, 54, 48, 51, 52, 51, 51, 57, 63, 68, 71, 73, 73, 75, 79, 83, 86, 88, 85, 81, 80, 80, 75, 73, 61, 52, 57, 57, 54, 56, 59, 65, 68, 71, 77, 79, 81, 83, 83, 84, 84, 84, 84, 85, 87, 85, 78, 68, 60, 55, 52, 51, 51, 53, 55, 59, 63, 67, 70, 72, 74, 74, 73, 73, 78, 84, 86, 79, 67, 58, 54, 52, 49, 44, 38, 35, 37, 41, 46, 50, 55, 59, 63, 67, 69, 72, 73, 74, 75, 75, 73, 68, 62, 56, 51, 47, 44, 44, 45, 47, 49, 52, 56, 61, 68, 73, 77, 80, 83, 86, 89, 90, 89, 87, 83, 76, 68, 60, 54, 50, 46, 43, 41, 41, 42, 45, 49, 54, 59, 63, 64, 64],
"wind_speed_10m": [6.2, 6.9, 7, 6.9, 6.5, 5.1, 7.2, 8.4, 9.4, 8.9, 8.9, 10.9, 10.2, 11.3, 12.2, 13, 9.4, 9, 7.1, 6.7, 6.2, 6.6, 7.1, 8, 7, 6.8, 7.1, 7.1, 7.7, 9.3, 10.8, 9, 10.8, 10.8, 12.3, 11.9, 12.2, 10.8, 9, 9.2, 10.1, 8.9, 7.6, 4.7, 2.6, 0.5, 1.8, 4.6, 3.6, 5.2, 5.4, 5.4, 4.8, 5.8, 10.4, 7.2, 8.7, 8.8, 10.1, 11.2, 12.6, 12.2, 11.9, 12.8, 12.4, 11.4, 9.1, 7.1, 6.6, 8, 8.3, 8.2, 8, 7.8, 8.1, 7.4, 7.2, 9, 13, 10.9, 8.3, 7.7, 9.4, 11.2, 12.6, 13, 12.6, 11.6, 9.8, 7.6, 5.8, 4.9, 5.2, 5.6, 5.9, 5.6, 5.9, 6.5, 7.2, 8, 8.9, 9.4, 10, 10.9, 13.3, 15.8, 16.9, 17.5, 17.3, 17.3, 16.6, 16.2, 15.5, 15.2, 14.6, 13.8, 13.3, 13, 13, 13.3, 13.7, 13.1, 12.8, 12.9, 13, 13.5, 14.1, 14.9, 16.2, 17.4, 17.8, 17.7, 17.7, 17.8, 17.7, 17.1, 15.7, 13.8, 11.9, 9.8, 7.9, 6.9, 6.6, 6.3, 6.3, 5.9, 5.4, 5, 4.7, 4.8, 5.2, 6.2, 7.1, 7.8, 7.2, 6.3, 5.4, 5.4, 5.8, 6.5, 6.1, 4.9, 4.1, 4.2, 4.9, 5.2, 4.8, 4.1]
}
}
Loading
Loading