diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e17da99..c1f8c25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,12 @@ jobs: cache: pip - name: Install dependencies - run: python -m pip install --upgrade pip && python -m pip install -r requirements.txt + run: python -m pip install --upgrade pip && python -m pip install -r requirements-dev.txt - name: Compile application and tests run: python -m compileall -q country_api tests main.py - - name: Run unit and API tests + - name: Run unit, API and contract tests run: python -m unittest discover -s tests -p "test_*.py" -v - name: Build deterministic fixture database through Flask CLI @@ -49,3 +49,12 @@ jobs: --from-year 2022 --to-year 2023 --fixture-dir data/fixtures + + - name: Verify persisted quality report + env: + COUNTRY_API_DATABASE: instance/ci-country-data.sqlite + run: >- + python -c "from country_api.database import data_quality_snapshot; + report = data_quality_snapshot('instance/ci-country-data.sqlite'); + assert report['status'] == 'passed', report; + print(report['status'])" diff --git a/README.md b/README.md index 3d71144..282dc35 100644 --- a/README.md +++ b/README.md @@ -2,80 +2,91 @@ [![CI](https://github.com/DataTideHH/flask-country-data-api/actions/workflows/ci.yml/badge.svg)](https://github.com/DataTideHH/flask-country-data-api/actions/workflows/ci.yml) -**Flask API and reproducible World Bank data workflow with SQLite persistence, request and source validation, explicit provenance, fixture-based tests and cross-platform CI.** +**Reproducible World Bank ingestion workflow with source validation, constrained SQLite persistence, versioned Flask endpoints, SQL data-quality checks, OpenAPI documentation and cross-platform automated tests.** -## Purpose +## Portfolio purpose -External data is not automatically ready for reliable application use. Source responses must be checked, normalized, stored under stable constraints and exposed through a documented contract. +This project demonstrates how external reference and indicator data can be turned into a controlled, explainable data service. -This project demonstrates that process with country metadata and the World Bank population indicator `SP.POP.TOTL`: +The implementation is deliberately focused on the parts that matter in Data/BI and process-oriented work: + +- understanding the source contract +- separating ingestion from data delivery +- validating and normalizing source records +- designing a relational model with constraints +- recording process execution and failure states +- exposing stable API responses +- checking persisted data with reviewable SQL +- documenting architecture, lineage and the public API contract ```text -World Bank API -→ source validation -→ normalization +World Bank API or versioned fixtures +→ validation and normalization → transactional SQLite persistence -→ versioned Flask API +→ SQL data-quality checks +→ versioned Flask read API ``` -The Flask routes read from SQLite. They do not call the external source during normal requests. Data retrieval is an explicit refresh process, which keeps API responses predictable and separates ingestion failures from read traffic. +Normal HTTP requests never call the World Bank directly. Data acquisition is an explicit refresh process, while the API reads the last successfully committed SQLite state. -## What the project demonstrates +## Technical evidence -- Flask application factory and blueprints -- external REST API integration with timeout and error handling -- deterministic fixture mode for local validation and CI -- input, source-shape and semantic validation -- relational SQLite modelling with foreign keys and constraints -- ingestion-run tracking -- versioned API endpoints and stable JSON error responses -- tests covering validation, persistence, routes and CLI behavior -- GitHub Actions on Python 3.12 for Ubuntu and Windows +| Area | Evidence in this repository | +|---|---| +| Python / Flask | Application factory, blueprints, CLI command and stable error handlers | +| External APIs | World Bank client with timeout and source-response validation | +| Reproducibility | Version-controlled source-shaped fixtures and deterministic CI | +| Data modelling | Normalized SQLite tables, keys, checks, foreign keys and indexes | +| Process observability | `ingestion_runs` records start, completion, status, row counts and failures | +| SQL / Data quality | Ten named checks executed from `sql/data_quality_queries.sql` | +| API design | Versioned endpoints, bounded parameters and consistent JSON contracts | +| Documentation | OpenAPI 3.1, architecture diagram, ERD, data dictionary and provenance mapping | +| Automated validation | Unit, persistence, route, CLI, quality and contract tests on Ubuntu and Windows | ## Architecture -```text -refresh-data CLI - | - +-- WorldBankClient -------- live source - | - +-- FixtureWorldBankClient - deterministic fixtures - | - v - validation + normalization - | - v - SQLite transaction - | - v - Flask read API +```mermaid +flowchart LR + WB[World Bank API] --> CLI[refresh-data CLI] + FX[Versioned fixtures] --> CLI + CLI --> VAL[Validation and normalization] + VAL --> DB[(SQLite)] + DB --> API[Flask read API] + DB --> DQ[SQL quality checks] + DQ --> API + API --> CLIENT[API consumer] ``` -## Data model - -### `countries` +Detailed decisions and component responsibilities are documented in [`docs/architecture.md`](docs/architecture.md). -Stores one normalized record per ISO-2 country code, including region, income level, capital city, coordinates and provenance. - -### `population_observations` +## Data model -Stores annual `SP.POP.TOTL` observations with a composite key of country code and year. +The persisted model contains: -### `ingestion_runs` +- `countries` — normalized country metadata and record provenance +- `population_observations` — annual `SP.POP.TOTL` observations +- `ingestion_runs` — operational history for refresh executions -Records refresh status, timestamps, requested countries, loaded rows and failure messages. +See: -See [`sql/schema.sql`](sql/schema.sql) and [`docs/data-quality-notes.md`](docs/data-quality-notes.md). +- [`sql/schema.sql`](sql/schema.sql) +- [`docs/data-model.md`](docs/data-model.md) +- [`docs/data-dictionary.md`](docs/data-dictionary.md) +- [`docs/data-provenance.md`](docs/data-provenance.md) ## API endpoints | Endpoint | Purpose | |---|---| | `GET /` | Service metadata and endpoint overview | -| `GET /health` | Database availability and row counts | +| `GET /health` | Database availability and basic row counts | | `GET /api/v1/countries` | List and filter countries | | `GET /api/v1/countries/` | Read one country with its latest population | | `GET /api/v1/countries//population` | Read a population time range | +| `GET /api/v1/summary` | Read dataset-level metrics | +| `GET /api/v1/data-quality` | Execute and report SQL data-quality checks | +| `GET /api/v1/ingestion-runs` | Read recent refresh executions | +| `GET /openapi/openapi.yaml` | Download the OpenAPI 3.1 contract | Examples: @@ -84,9 +95,73 @@ GET /api/v1/countries?codes=DE,US,JP&limit=10 GET /api/v1/countries?region=North%20America GET /api/v1/countries/DE GET /api/v1/countries/DE/population?from=2022&to=2023 +GET /api/v1/summary +GET /api/v1/data-quality +GET /api/v1/ingestion-runs?limit=5 ``` -Stable error contract: +The complete contract is versioned in [`openapi/openapi.yaml`](openapi/openapi.yaml). + +## Summary response + +```json +{ + "data": { + "countries": 3, + "populationObservations": 6, + "latestObservationYear": 2023, + "missingPopulationValues": 0, + "countriesWithoutPopulation": 0, + "lastSuccessfulIngestion": "2026-07-27T17:00:00+00:00" + } +} +``` + +The timestamp above is illustrative. Actual values come from the local database. + +## Data-quality reporting + +The API executes the named SQL statements in [`sql/data_quality_queries.sql`](sql/data_quality_queries.sql). + +Checks cover: + +- duplicate and malformed country keys +- missing country names +- invalid coordinates +- duplicate or orphaned population observations +- negative population values +- unexpected indicator codes +- countries without population history +- ingestion runs left incomplete + +Example shape: + +```json +{ + "data": { + "status": "passed", + "errorViolations": 0, + "warningViolations": 0, + "checks": [ + { + "name": "orphan_population_observations", + "severity": "error", + "description": "Every population observation must reference an existing country.", + "violations": 0, + "passed": true + } + ], + "provenance": { + "queryFile": "sql/data_quality_queries.sql", + "lastSuccessfulIngestion": "2026-07-27T17:00:00+00:00" + } + } +} +``` + +See [`docs/data-quality-notes.md`](docs/data-quality-notes.md) for the complete rule set and status logic. + +## Stable error contract ```json { @@ -138,7 +213,7 @@ python -m flask --app country_api:create_app run --port 8080 The fixture command creates a deterministic local database without network access. -## Refresh from the live World Bank API +## Live World Bank refresh Remove `--fixture-dir` to use the live source explicitly: @@ -149,16 +224,31 @@ python -m flask --app country_api:create_app refresh-data \ --to-year 2024 ``` -Normal API requests continue to read only from the local SQLite database. +A failed live refresh is recorded in `ingestion_runs` and does not replace previously committed data. + +## Tests and CI -## Tests +Install the development requirements: + +```powershell +python -m pip install -r requirements-dev.txt +``` + +Run the same core checks used in CI: ```powershell python -m compileall -q country_api tests main.py python -m unittest discover -s tests -p "test_*.py" -v ``` -The test suite uses versioned World Bank-shaped fixtures and does not perform live HTTP requests. +GitHub Actions validates Python 3.12 on Ubuntu and Windows. The workflow: + +1. compiles application and test modules +2. runs unit, persistence, route, CLI, data-quality and OpenAPI tests +3. builds a deterministic SQLite database from fixtures +4. executes the persisted SQL quality report and requires `passed` + +Automated tests never perform live HTTP requests. ## Repository structure @@ -177,20 +267,28 @@ flask-country-data-api/ ├── data/fixtures/ │ ├── world-bank-countries.json │ └── world-bank-population.json -├── docs/data-quality-notes.md -├── examples/sample-response.json -├── sql/schema.sql +├── docs/ +│ ├── architecture.md +│ ├── data-dictionary.md +│ ├── data-model.md +│ ├── data-provenance.md +│ └── data-quality-notes.md +├── openapi/openapi.yaml +├── sql/ +│ ├── data_quality_queries.sql +│ └── schema.sql ├── tests/ +│ ├── test_openapi.py +│ ├── test_reporting.py │ ├── test_routes_and_cli.py │ ├── test_service_and_database.py │ └── test_validation.py ├── main.py +├── requirements-dev.txt ├── requirements.txt └── README.md ``` ## Scope boundary -This is a deliberately bounded portfolio project. It demonstrates controlled data ingestion and API delivery without adding an unnecessary frontend, authentication system, container platform or cloud deployment. - -The next increment can add richer data-quality reporting, OpenAPI documentation and recruiter-facing architecture evidence without changing the deterministic core. +This is a deliberately bounded portfolio project. It demonstrates controlled ingestion, relational persistence, process observability, data quality and API delivery without adding an unrelated frontend, authentication system, container platform or cloud deployment. diff --git a/country_api/database.py b/country_api/database.py index a31c283..c6a6a54 100644 --- a/country_api/database.py +++ b/country_api/database.py @@ -7,7 +7,52 @@ from typing import Any, Iterable -SCHEMA_PATH = Path(__file__).resolve().parents[1] / "sql" / "schema.sql" +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = PROJECT_ROOT / "sql" / "schema.sql" +DATA_QUALITY_QUERIES_PATH = PROJECT_ROOT / "sql" / "data_quality_queries.sql" + +DATA_QUALITY_METADATA = { + "duplicate_country_codes": ( + "error", + "Country codes must be unique.", + ), + "invalid_country_codes": ( + "error", + "Country codes must contain exactly two uppercase letters.", + ), + "missing_country_names": ( + "error", + "Every country record must contain a non-empty name.", + ), + "invalid_coordinates": ( + "error", + "Stored coordinates must remain inside valid geographic ranges.", + ), + "duplicate_population_observations": ( + "error", + "Each country and year may have at most one population observation.", + ), + "orphan_population_observations": ( + "error", + "Every population observation must reference an existing country.", + ), + "negative_population_values": ( + "error", + "Population values cannot be negative.", + ), + "unexpected_indicator_codes": ( + "error", + "Population rows must use the SP.POP.TOTL indicator.", + ), + "countries_without_population": ( + "warning", + "Countries should normally have at least one population observation.", + ), + "incomplete_ingestion_runs": ( + "warning", + "Ingestion runs should not remain in running status indefinitely.", + ), +} @contextmanager @@ -277,3 +322,158 @@ def health_snapshot(database_path: str | Path) -> dict[str, int | str | None]: "populationObservations": int(observations), "lastSuccessfulIngestion": last_run[0] if last_run else None, } + + +def summary_snapshot(database_path: str | Path) -> dict[str, int | str | None]: + with connect_database(database_path) as connection: + row = connection.execute( + """ + SELECT + (SELECT COUNT(*) FROM countries) AS countries, + (SELECT COUNT(*) FROM population_observations) AS observations, + (SELECT MAX(observation_year) FROM population_observations) AS latest_year, + ( + SELECT COUNT(*) + FROM population_observations + WHERE population IS NULL + ) AS missing_population_values, + ( + SELECT COUNT(*) + FROM countries AS c + WHERE NOT EXISTS ( + SELECT 1 + FROM population_observations AS p + WHERE p.iso2_code = c.iso2_code + ) + ) AS countries_without_population, + ( + SELECT completed_at + FROM ingestion_runs + WHERE status = 'success' + ORDER BY run_id DESC + LIMIT 1 + ) AS last_successful_ingestion + """ + ).fetchone() + + return { + "countries": int(row["countries"]), + "populationObservations": int(row["observations"]), + "latestObservationYear": row["latest_year"], + "missingPopulationValues": int(row["missing_population_values"]), + "countriesWithoutPopulation": int(row["countries_without_population"]), + "lastSuccessfulIngestion": row["last_successful_ingestion"], + } + + +def list_ingestion_runs( + database_path: str | Path, + *, + limit: int = 20, +) -> list[dict[str, Any]]: + with connect_database(database_path) as connection: + rows = connection.execute( + """ + SELECT run_id, started_at, completed_at, status, countries_requested, + countries_loaded, observations_loaded, rejected_records, error_message + FROM ingestion_runs + ORDER BY run_id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + return [ + { + "runId": row["run_id"], + "startedAt": row["started_at"], + "completedAt": row["completed_at"], + "status": row["status"], + "countriesRequested": row["countries_requested"], + "countriesLoaded": row["countries_loaded"], + "observationsLoaded": row["observations_loaded"], + "rejectedRecords": row["rejected_records"], + "errorMessage": row["error_message"], + } + for row in rows + ] + + +def _load_named_queries(path: Path = DATA_QUALITY_QUERIES_PATH) -> dict[str, str]: + queries: dict[str, str] = {} + current_name: str | None = None + current_lines: list[str] = [] + + def store_current_query() -> None: + if current_name is None: + return + query = "\n".join(current_lines).strip() + if not query: + raise ValueError(f"Data-quality query {current_name!r} is empty.") + queries[current_name] = query + + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("-- name:"): + store_current_query() + current_name = line.partition(":")[2].strip() + current_lines = [] + elif current_name is not None: + current_lines.append(line) + + store_current_query() + + if set(queries) != set(DATA_QUALITY_METADATA): + missing = sorted(set(DATA_QUALITY_METADATA) - set(queries)) + unexpected = sorted(set(queries) - set(DATA_QUALITY_METADATA)) + raise ValueError( + "Data-quality query definitions do not match metadata. " + f"Missing: {missing}; unexpected: {unexpected}." + ) + return queries + + +def data_quality_snapshot(database_path: str | Path) -> dict[str, Any]: + named_queries = _load_named_queries() + checks: list[dict[str, Any]] = [] + + with connect_database(database_path) as connection: + for name, query in named_queries.items(): + row = connection.execute(query).fetchone() + violations = int(row["violations"]) + severity, description = DATA_QUALITY_METADATA[name] + checks.append( + { + "name": name, + "severity": severity, + "description": description, + "violations": violations, + "passed": violations == 0, + } + ) + + error_violations = sum( + check["violations"] for check in checks if check["severity"] == "error" + ) + warning_violations = sum( + check["violations"] for check in checks if check["severity"] == "warning" + ) + + if error_violations: + status = "failed" + elif warning_violations: + status = "warning" + else: + status = "passed" + + return { + "status": status, + "errorViolations": error_violations, + "warningViolations": warning_violations, + "checks": checks, + "provenance": { + "queryFile": "sql/data_quality_queries.sql", + "lastSuccessfulIngestion": summary_snapshot(database_path)[ + "lastSuccessfulIngestion" + ], + }, + } diff --git a/country_api/routes.py b/country_api/routes.py index 08d9695..8b459d5 100644 --- a/country_api/routes.py +++ b/country_api/routes.py @@ -3,13 +3,17 @@ import sqlite3 from datetime import datetime -from flask import Blueprint, current_app, jsonify, request +from flask import Blueprint, current_app, jsonify, request, send_from_directory from country_api.database import ( + PROJECT_ROOT, + data_quality_snapshot, get_country, get_population_history, health_snapshot, list_countries, + list_ingestion_runs, + summary_snapshot, ) from country_api.errors import ApiError from country_api.validation import ( @@ -42,16 +46,29 @@ def index(): { "service": "flask-country-data-api", "description": "Deterministic country metadata and population API backed by SQLite.", + "documentation": "/openapi/openapi.yaml", "endpoints": [ "/health", "/api/v1/countries", "/api/v1/countries/", "/api/v1/countries//population", + "/api/v1/summary", + "/api/v1/data-quality", + "/api/v1/ingestion-runs", ], } ) +@api.get("/openapi/openapi.yaml") +def openapi_document(): + return send_from_directory( + PROJECT_ROOT / "openapi", + "openapi.yaml", + mimetype="application/yaml", + ) + + @api.get("/health") def health(): try: @@ -141,3 +158,24 @@ def population_history(code: str): }, } ) + + +@api.get("/api/v1/summary") +def summary(): + return jsonify({"data": summary_snapshot(_database_path())}) + + +@api.get("/api/v1/data-quality") +def data_quality(): + return jsonify({"data": data_quality_snapshot(_database_path())}) + + +@api.get("/api/v1/ingestion-runs") +def ingestion_runs(): + try: + limit = parse_limit(request.args.get("limit"), default=20, maximum=100) + except InputValidationError as exc: + raise _bad_request(exc) from exc + + data = list_ingestion_runs(_database_path(), limit=limit) + return jsonify({"data": data, "meta": {"count": len(data), "limit": limit}}) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b8065af --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,83 @@ +# Architecture + +## System context + +The project separates data acquisition from API delivery. External source availability therefore does not determine whether read requests can be served. + +```mermaid +flowchart LR + WB[World Bank API] -->|explicit refresh| CLI[Flask refresh-data CLI] + FX[Versioned fixtures] -->|deterministic refresh| CLI + CLI --> VAL[Source validation and normalization] + VAL --> TX[SQLite transaction] + TX --> C[(countries)] + TX --> P[(population_observations)] + CLI --> R[(ingestion_runs)] + C --> API[Flask read API] + P --> API + R --> API + DQ[SQL data-quality checks] --> API + C --> DQ + P --> DQ + R --> DQ + API --> CLIENT[API consumer] +``` + +## Request and ingestion paths + +### Ingestion path + +```text +refresh-data command +→ World Bank client or fixture client +→ source-shape validation +→ semantic normalization +→ constrained SQLite transaction +→ ingestion status update +``` + +### Read path + +```text +HTTP request +→ query/path validation +→ SQLite read model +→ stable JSON response +``` + +The read path never performs a live World Bank request. + +## Component responsibilities + +| Component | Responsibility | +|---|---| +| `country_api/world_bank.py` | Retrieve live or fixture-shaped World Bank responses | +| `country_api/validation.py` | Validate request values and normalize source records | +| `country_api/service.py` | Coordinate refresh runs and ingestion status transitions | +| `country_api/database.py` | Manage transactions, read models, summaries and quality checks | +| `country_api/routes.py` | Expose versioned HTTP endpoints and stable response contracts | +| `sql/schema.sql` | Define relational tables, constraints and indexes | +| `sql/data_quality_queries.sql` | Define named, reviewable data-quality checks | +| `openapi/openapi.yaml` | Describe the public HTTP contract | + +## Design decisions + +### Explicit refresh instead of source calls in routes + +This limits latency variance, avoids coupling API availability to an external service and makes failure states observable through `ingestion_runs`. + +### SQLite as a bounded persistence layer + +SQLite is appropriate for a small portfolio project because it demonstrates relational modelling, constraints, SQL querying and transactions without adding operational infrastructure that does not support the learning objective. + +### Versioned fixtures in automated tests + +CI must remain deterministic and must not depend on network access, changing source data or external rate limits. Fixtures preserve the relevant source shape while keeping test expectations stable. + +### SQL checks as version-controlled evidence + +Data-quality rules are stored in a dedicated SQL file and executed by the application. This keeps quality logic inspectable by both Python and SQL reviewers. + +## Scope boundary + +The architecture intentionally excludes authentication, a frontend, container orchestration, caching and cloud deployment. Those capabilities would not strengthen the current evidence as much as the implemented focus on controlled ingestion, data modelling, validation, observability and API contracts. diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md new file mode 100644 index 0000000..4aaf520 --- /dev/null +++ b/docs/data-dictionary.md @@ -0,0 +1,53 @@ +# Data Dictionary + +## `countries` + +| Column | Type | Null | Key / constraint | Meaning | +|---|---|---:|---|---| +| `iso2_code` | TEXT | No | Primary key; two uppercase characters | ISO-2-style country identifier used by the API | +| `country_name` | TEXT | No | Non-empty | Normalized country display name | +| `region_name` | TEXT | Yes | — | World Bank region name | +| `income_level` | TEXT | Yes | — | World Bank income classification | +| `capital_city` | TEXT | Yes | — | Capital city from the source response | +| `longitude` | REAL | Yes | `-180` to `180` | Capital-city longitude where supplied | +| `latitude` | REAL | Yes | `-90` to `90` | Capital-city latitude where supplied | +| `source_name` | TEXT | No | — | Source label used for the stored record | +| `fetched_at` | TEXT | No | ISO-8601 UTC timestamp | Time at which the source record was retrieved or loaded | + +## `population_observations` + +| Column | Type | Null | Key / constraint | Meaning | +|---|---|---:|---|---| +| `iso2_code` | TEXT | No | Composite primary key; foreign key | Country identifier referencing `countries.iso2_code` | +| `observation_year` | INTEGER | No | Composite primary key; year >= 1960 | Calendar year of the observation | +| `population` | INTEGER | Yes | Non-negative | Population value; `NULL` means the source supplied no value | +| `indicator_code` | TEXT | No | Must equal `SP.POP.TOTL` | World Bank total-population indicator | +| `source_name` | TEXT | No | — | Source label used for the observation | +| `fetched_at` | TEXT | No | ISO-8601 UTC timestamp | Time at which the observation was retrieved or loaded | + +## `ingestion_runs` + +| Column | Type | Null | Key / constraint | Meaning | +|---|---|---:|---|---| +| `run_id` | INTEGER | No | Primary key, auto-increment | Internal identifier for one refresh invocation | +| `started_at` | TEXT | No | ISO-8601 UTC timestamp | Start time of the refresh run | +| `completed_at` | TEXT | Yes | ISO-8601 UTC timestamp | Completion or failure time | +| `status` | TEXT | No | `running`, `success`, `failed` | Current/final run state | +| `countries_requested` | INTEGER | No | Non-negative | Number of distinct requested country codes | +| `countries_loaded` | INTEGER | No | Non-negative | Number of validated country rows persisted | +| `observations_loaded` | INTEGER | No | Non-negative | Number of validated population rows persisted | +| `rejected_records` | INTEGER | No | Non-negative | Reserved count for explicitly rejected source records | +| `error_message` | TEXT | Yes | Truncated to 500 characters by the application | Failure context for unsuccessful runs | + +## API naming convention + +SQLite columns use `snake_case`. JSON responses use `camelCase`, for example: + +```text +iso2_code → countryCode +observation_year → year +fetched_at → fetchedAt +countries_loaded → countriesLoaded +``` + +This keeps SQL idiomatic while providing a stable web-API contract. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..d981112 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,70 @@ +# Data Model + +## Entity relationship diagram + +```mermaid +erDiagram + COUNTRIES ||--o{ POPULATION_OBSERVATIONS : has + + COUNTRIES { + TEXT iso2_code PK + TEXT country_name + TEXT region_name + TEXT income_level + TEXT capital_city + REAL longitude + REAL latitude + TEXT source_name + TEXT fetched_at + } + + POPULATION_OBSERVATIONS { + TEXT iso2_code PK, FK + INTEGER observation_year PK + INTEGER population + TEXT indicator_code + TEXT source_name + TEXT fetched_at + } + + INGESTION_RUNS { + INTEGER run_id PK + TEXT started_at + TEXT completed_at + TEXT status + INTEGER countries_requested + INTEGER countries_loaded + INTEGER observations_loaded + INTEGER rejected_records + TEXT error_message + } +``` + +`ingestion_runs` is an operational audit table. It records the execution state of refresh operations but is intentionally not used as a foreign-key parent for source rows. Country and population rows retain their own source and retrieval timestamps so their provenance remains available independently of run-history retention. + +## Keys and cardinality + +- `countries.iso2_code` is the stable country key. +- `population_observations` uses the composite primary key `(iso2_code, observation_year)`. +- One country can have zero or many annual population observations. +- Each observation must reference an existing country. +- One ingestion run represents one invocation of the refresh process. + +## Integrity rules + +The schema enforces: + +- uppercase two-character country keys +- non-empty country names +- valid longitude and latitude ranges +- non-negative or missing population values +- indicator code `SP.POP.TOTL` +- unique country/year observations +- referential integrity between observations and countries +- controlled ingestion statuses: `running`, `success`, `failed` + +## Read model + +Country API responses combine `countries` with the latest available population observation through a correlated subquery. Population history remains available as a separate time-series endpoint. + +The summary and data-quality endpoints aggregate the same persisted tables rather than maintaining separate derived storage. diff --git a/docs/data-provenance.md b/docs/data-provenance.md new file mode 100644 index 0000000..880f17e --- /dev/null +++ b/docs/data-provenance.md @@ -0,0 +1,64 @@ +# Data Provenance + +## Source boundary + +The live ingestion client reads: + +- World Bank country metadata +- World Bank indicator `SP.POP.TOTL` for annual total population + +The application does not treat the external response as application-ready data. Each source record passes through validation and normalization before persistence. + +## Field lineage + +| API field | SQLite field | Source / derivation | +|---|---|---| +| `countryCode` | `countries.iso2_code` | World Bank `iso2Code`, normalized to uppercase and checked against the requested code | +| `countryName` | `countries.country_name` | World Bank `name` | +| `region` | `countries.region_name` | World Bank `region.value`; aggregate records are rejected | +| `incomeLevel` | `countries.income_level` | World Bank `incomeLevel.value` | +| `capitalCity` | `countries.capital_city` | World Bank `capitalCity` | +| `coordinates.longitude` | `countries.longitude` | World Bank `longitude`, converted to numeric and range-checked | +| `coordinates.latitude` | `countries.latitude` | World Bank `latitude`, converted to numeric and range-checked | +| `latestPopulation.year` | `population_observations.observation_year` | Maximum stored observation year for the country | +| `latestPopulation.value` | `population_observations.population` | World Bank `SP.POP.TOTL` value; missing source values remain `NULL` | +| `provenance.source` | `source_name` | Client label: live API or fixture snapshot | +| `provenance.fetchedAt` | `fetched_at` | UTC timestamp assigned at refresh start | + +## Live and fixture modes + +### Live mode + +The refresh CLI calls the World Bank API explicitly. The stored source label is `World Bank API`. + +### Fixture mode + +Version-controlled JSON fixtures preserve a representative World Bank response shape for tests, demonstrations and CI. The stored source label is `World Bank fixture snapshot`. + +Fixture values are not presented as a continuously current dataset. Their purpose is deterministic validation of mapping, persistence, quality checks and API behavior. + +## Temporal semantics + +Two different times are retained: + +- `observation_year` describes when the population measurement applies. +- `fetched_at` describes when this application retrieved or loaded the record. + +The API must not imply that retrieval time is the observation time. + +## Refresh and replacement behavior + +For each requested country and year range: + +1. source data is retrieved and validated in memory +2. country records are inserted or updated +3. existing population rows inside the requested range are deleted +4. validated source observations are inserted +5. the transaction commits +6. the ingestion run is marked successful + +If validation or persistence fails, the data transaction rolls back and the run is marked failed. Previously committed data remains available to read clients. + +## Quality and authority statement + +The service preserves source attribution and validates structure, identity, ranges and relational integrity. It does not independently certify the statistical methodology or publication process of the upstream source. diff --git a/docs/data-quality-notes.md b/docs/data-quality-notes.md index 8edb795..5938d9f 100644 --- a/docs/data-quality-notes.md +++ b/docs/data-quality-notes.md @@ -1,4 +1,4 @@ -# Data Quality Notes +# Data Quality The service separates source retrieval from API delivery: @@ -7,20 +7,74 @@ World Bank API or versioned fixture → source-shape validation → normalization → transactional SQLite persistence +→ SQL data-quality checks → Flask read API ``` -The deterministic core enforces these rules: +## Quality layers -- country codes must be two uppercase letters +### 1. Request validation + +- country codes must contain exactly two letters +- values are normalized to uppercase - duplicate requested codes are removed while preserving order -- one country record must match each requested code -- World Bank aggregates are rejected as countries +- request size and numeric query parameters are bounded + +### 2. Source validation + +- the returned country must match the requested code +- World Bank aggregate records are rejected as countries +- names and nested source structures must have the expected shape - coordinates must fall inside valid geographic ranges -- population values may be missing but cannot be negative -- population observations use indicator `SP.POP.TOTL` -- foreign keys prevent observations without countries -- one population value is stored per country and year -- each ingestion run records success or failure +- population rows must use indicator `SP.POP.TOTL` +- observation years must be plausible +- missing population values remain `NULL`; negative values are rejected + +### 3. Relational constraints + +- country codes are primary keys +- one population row is allowed per country and year +- population rows require an existing country +- population and coordinate ranges are constrained in SQLite +- ingestion status values are controlled + +### 4. Version-controlled SQL checks + +The application executes the named queries in [`sql/data_quality_queries.sql`](../sql/data_quality_queries.sql). + +| Check | Severity | Purpose | +|---|---|---| +| `duplicate_country_codes` | Error | Detect duplicate country keys | +| `invalid_country_codes` | Error | Detect malformed stored codes | +| `missing_country_names` | Error | Detect empty required names | +| `invalid_coordinates` | Error | Detect out-of-range coordinates | +| `duplicate_population_observations` | Error | Detect duplicate country/year rows | +| `orphan_population_observations` | Error | Detect broken country references | +| `negative_population_values` | Error | Detect invalid negative measures | +| `unexpected_indicator_codes` | Error | Detect non-population indicators | +| `countries_without_population` | Warning | Identify countries without observations | +| `incomplete_ingestion_runs` | Warning | Identify runs left in `running` state | + +The `/api/v1/data-quality` endpoint returns each check, its severity, violation count and pass/fail state. The overall status is: + +- `passed` when no violations exist +- `warning` when only warning checks have violations +- `failed` when at least one error check has violations + +## Process observability + +Every refresh invocation creates an `ingestion_runs` row before source access begins. The run is then completed as `success` or `failed`, preserving counts, timestamps and a bounded error message. + +This makes a failed refresh visible without overwriting the last committed dataset. + +## Deterministic validation Versioned fixtures are used for tests and CI. Live World Bank calls are available only through the explicit refresh command and are never required for automated tests. + +CI verifies: + +- Python compilation +- unit, persistence, route and OpenAPI tests +- deterministic fixture ingestion +- a persisted `passed` data-quality report +- identical behavior on Python 3.12 for Ubuntu and Windows diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..48b65d3 --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,505 @@ +openapi: 3.1.0 +info: + title: Flask Country Data API + version: 1.0.0 + description: >- + Deterministic read API for normalized World Bank country metadata, + population observations, ingestion history and data-quality results. +servers: + - url: http://127.0.0.1:8080 + description: Local development server +paths: + /: + get: + summary: Describe the service + operationId: getServiceMetadata + responses: + '200': + description: Service metadata + content: + application/json: + schema: + type: object + required: [service, description, documentation, endpoints] + properties: + service: + type: string + const: flask-country-data-api + description: + type: string + documentation: + type: string + endpoints: + type: array + items: + type: string + /health: + get: + summary: Check database availability + operationId: getHealth + responses: + '200': + description: Database is available + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + '503': + $ref: '#/components/responses/DatabaseUnavailable' + /api/v1/countries: + get: + summary: List countries + operationId: listCountries + parameters: + - name: codes + in: query + description: Comma-separated two-letter country codes; duplicates are removed. + schema: + type: string + example: DE,US,JP + - name: region + in: query + schema: + type: string + example: North America + - name: income_level + in: query + schema: + type: string + example: High income + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Matching countries + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Country' + meta: + $ref: '#/components/schemas/ListMeta' + '400': + $ref: '#/components/responses/InvalidQuery' + /api/v1/countries/{code}: + get: + summary: Read one country + operationId: getCountry + parameters: + - $ref: '#/components/parameters/CountryCode' + responses: + '200': + description: Country details + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/Country' + '400': + $ref: '#/components/responses/InvalidQuery' + '404': + $ref: '#/components/responses/CountryNotFound' + /api/v1/countries/{code}/population: + get: + summary: Read population history + operationId: getPopulationHistory + parameters: + - $ref: '#/components/parameters/CountryCode' + - name: from + in: query + schema: + type: integer + minimum: 1960 + example: 2022 + - name: to + in: query + schema: + type: integer + minimum: 1960 + example: 2023 + responses: + '200': + description: Population observations in ascending year order + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/PopulationObservation' + meta: + type: object + required: [countryCode, fromYear, toYear, count] + properties: + countryCode: + type: string + pattern: '^[A-Z]{2}$' + fromYear: + type: integer + toYear: + type: integer + count: + type: integer + minimum: 0 + '400': + $ref: '#/components/responses/InvalidQuery' + '404': + $ref: '#/components/responses/CountryNotFound' + /api/v1/summary: + get: + summary: Read dataset summary metrics + operationId: getSummary + responses: + '200': + description: Current persisted dataset summary + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/Summary' + /api/v1/data-quality: + get: + summary: Execute version-controlled SQL data-quality checks + operationId: getDataQuality + responses: + '200': + description: Data-quality status and individual checks + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/DataQualityReport' + /api/v1/ingestion-runs: + get: + summary: List recent ingestion runs + operationId: listIngestionRuns + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Recent refresh executions in descending run order + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/IngestionRun' + meta: + $ref: '#/components/schemas/ListMeta' + '400': + $ref: '#/components/responses/InvalidQuery' + /openapi/openapi.yaml: + get: + summary: Download this OpenAPI document + operationId: getOpenApiDocument + responses: + '200': + description: OpenAPI YAML document + content: + application/yaml: + schema: + type: string +components: + parameters: + CountryCode: + name: code + in: path + required: true + description: Two-letter country code + schema: + type: string + pattern: '^[A-Za-z]{2}$' + example: DE + responses: + InvalidQuery: + description: Invalid path or query parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: invalid_query_parameter + message: limit must be between 1 and 100. + CountryNotFound: + description: Country code is valid but not present in the database + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: country_not_found + message: No country was found for code ZZ. + details: + countryCode: ZZ + DatabaseUnavailable: + description: SQLite database cannot be read + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + schemas: + Provenance: + type: object + required: [source, fetchedAt] + properties: + source: + type: string + fetchedAt: + type: string + format: date-time + LatestPopulation: + type: [object, 'null'] + required: [year, value] + properties: + year: + type: integer + minimum: 1960 + value: + type: [integer, 'null'] + minimum: 0 + Coordinates: + type: object + required: [longitude, latitude] + properties: + longitude: + type: [number, 'null'] + minimum: -180 + maximum: 180 + latitude: + type: [number, 'null'] + minimum: -90 + maximum: 90 + Country: + type: object + required: + - countryCode + - countryName + - region + - incomeLevel + - capitalCity + - coordinates + - latestPopulation + - provenance + properties: + countryCode: + type: string + pattern: '^[A-Z]{2}$' + countryName: + type: string + minLength: 1 + region: + type: [string, 'null'] + incomeLevel: + type: [string, 'null'] + capitalCity: + type: [string, 'null'] + coordinates: + $ref: '#/components/schemas/Coordinates' + latestPopulation: + $ref: '#/components/schemas/LatestPopulation' + provenance: + $ref: '#/components/schemas/Provenance' + PopulationObservation: + type: object + required: [year, value, indicatorCode, provenance] + properties: + year: + type: integer + minimum: 1960 + value: + type: [integer, 'null'] + minimum: 0 + indicatorCode: + type: string + const: SP.POP.TOTL + provenance: + $ref: '#/components/schemas/Provenance' + Health: + type: object + required: [status, countries, populationObservations, lastSuccessfulIngestion] + properties: + status: + type: string + const: ok + countries: + type: integer + minimum: 0 + populationObservations: + type: integer + minimum: 0 + lastSuccessfulIngestion: + type: [string, 'null'] + format: date-time + Summary: + type: object + required: + - countries + - populationObservations + - latestObservationYear + - missingPopulationValues + - countriesWithoutPopulation + - lastSuccessfulIngestion + properties: + countries: + type: integer + minimum: 0 + populationObservations: + type: integer + minimum: 0 + latestObservationYear: + type: [integer, 'null'] + missingPopulationValues: + type: integer + minimum: 0 + countriesWithoutPopulation: + type: integer + minimum: 0 + lastSuccessfulIngestion: + type: [string, 'null'] + format: date-time + DataQualityCheck: + type: object + required: [name, severity, description, violations, passed] + properties: + name: + type: string + severity: + type: string + enum: [error, warning] + description: + type: string + violations: + type: integer + minimum: 0 + passed: + type: boolean + DataQualityReport: + type: object + required: + - status + - errorViolations + - warningViolations + - checks + - provenance + properties: + status: + type: string + enum: [passed, warning, failed] + errorViolations: + type: integer + minimum: 0 + warningViolations: + type: integer + minimum: 0 + checks: + type: array + items: + $ref: '#/components/schemas/DataQualityCheck' + provenance: + type: object + required: [queryFile, lastSuccessfulIngestion] + properties: + queryFile: + type: string + const: sql/data_quality_queries.sql + lastSuccessfulIngestion: + type: [string, 'null'] + format: date-time + IngestionRun: + type: object + required: + - runId + - startedAt + - completedAt + - status + - countriesRequested + - countriesLoaded + - observationsLoaded + - rejectedRecords + - errorMessage + properties: + runId: + type: integer + minimum: 1 + startedAt: + type: string + format: date-time + completedAt: + type: [string, 'null'] + format: date-time + status: + type: string + enum: [running, success, failed] + countriesRequested: + type: integer + minimum: 0 + countriesLoaded: + type: integer + minimum: 0 + observationsLoaded: + type: integer + minimum: 0 + rejectedRecords: + type: integer + minimum: 0 + errorMessage: + type: [string, 'null'] + ListMeta: + type: object + required: [count, limit] + properties: + count: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..14e8787 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +PyYAML>=6.0,<7 diff --git a/sql/data_quality_queries.sql b/sql/data_quality_queries.sql new file mode 100644 index 0000000..c197e6f --- /dev/null +++ b/sql/data_quality_queries.sql @@ -0,0 +1,67 @@ +-- name: duplicate_country_codes +SELECT COUNT(*) AS violations +FROM ( + SELECT iso2_code + FROM countries + GROUP BY iso2_code + HAVING COUNT(*) > 1 +); + +-- name: invalid_country_codes +SELECT COUNT(*) AS violations +FROM countries +WHERE length(iso2_code) <> 2 + OR iso2_code <> upper(iso2_code) + OR iso2_code GLOB '*[^A-Z]*'; + +-- name: missing_country_names +SELECT COUNT(*) AS violations +FROM countries +WHERE country_name IS NULL + OR length(trim(country_name)) = 0; + +-- name: invalid_coordinates +SELECT COUNT(*) AS violations +FROM countries +WHERE (longitude IS NOT NULL AND (longitude < -180 OR longitude > 180)) + OR (latitude IS NOT NULL AND (latitude < -90 OR latitude > 90)); + +-- name: duplicate_population_observations +SELECT COUNT(*) AS violations +FROM ( + SELECT iso2_code, observation_year + FROM population_observations + GROUP BY iso2_code, observation_year + HAVING COUNT(*) > 1 +); + +-- name: orphan_population_observations +SELECT COUNT(*) AS violations +FROM population_observations AS p +LEFT JOIN countries AS c + ON c.iso2_code = p.iso2_code +WHERE c.iso2_code IS NULL; + +-- name: negative_population_values +SELECT COUNT(*) AS violations +FROM population_observations +WHERE population < 0; + +-- name: unexpected_indicator_codes +SELECT COUNT(*) AS violations +FROM population_observations +WHERE indicator_code <> 'SP.POP.TOTL'; + +-- name: countries_without_population +SELECT COUNT(*) AS violations +FROM countries AS c +WHERE NOT EXISTS ( + SELECT 1 + FROM population_observations AS p + WHERE p.iso2_code = c.iso2_code +); + +-- name: incomplete_ingestion_runs +SELECT COUNT(*) AS violations +FROM ingestion_runs +WHERE status = 'running'; diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..615c7f8 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +import yaml + +from country_api import create_app + + +OPENAPI_PATH = Path(__file__).resolve().parents[1] / "openapi" / "openapi.yaml" + + +class OpenApiContractTest(unittest.TestCase): + def test_openapi_document_is_valid_yaml_with_expected_paths(self): + document = yaml.safe_load(OPENAPI_PATH.read_text(encoding="utf-8")) + + self.assertEqual(document["openapi"], "3.1.0") + self.assertEqual(document["info"]["title"], "Flask Country Data API") + + expected_paths = { + "/", + "/health", + "/api/v1/countries", + "/api/v1/countries/{code}", + "/api/v1/countries/{code}/population", + "/api/v1/summary", + "/api/v1/data-quality", + "/api/v1/ingestion-runs", + "/openapi/openapi.yaml", + } + self.assertEqual(set(document["paths"]), expected_paths) + + operation_ids = [ + operation["operationId"] + for path_item in document["paths"].values() + for method, operation in path_item.items() + if method in {"get", "post", "put", "patch", "delete"} + ] + self.assertEqual(len(operation_ids), len(set(operation_ids))) + + def test_openapi_document_is_served_by_the_application(self): + with tempfile.TemporaryDirectory() as temporary_directory: + app = create_app( + { + "TESTING": True, + "DATABASE_PATH": str(Path(temporary_directory) / "country.sqlite"), + } + ) + response = app.test_client().get("/openapi/openapi.yaml") + + self.assertEqual(response.status_code, 200) + self.assertIn("application/yaml", response.content_type) + self.assertIn(b"openapi: 3.1.0", response.data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..a8ec099 --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from country_api import create_app +from country_api.database import ( + connect_database, + data_quality_snapshot, + list_ingestion_runs, + summary_snapshot, +) +from country_api.service import refresh_country_data +from country_api.world_bank import FixtureWorldBankClient + + +FIXTURE_DIRECTORY = Path(__file__).resolve().parents[1] / "data" / "fixtures" + + +class FailingCountryClient: + source_name = "failing test source" + + def fetch_country(self, code: str) -> dict: + raise RuntimeError(f"Source unavailable for {code}") + + def fetch_population(self, code: str, from_year: int, to_year: int) -> list[dict]: + return [] + + +class ReportingTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.database_path = Path(self.temporary_directory.name) / "country.sqlite" + self.app = create_app( + { + "TESTING": True, + "DATABASE_PATH": str(self.database_path), + } + ) + refresh_country_data( + self.database_path, + client=FixtureWorldBankClient(FIXTURE_DIRECTORY), + country_codes=["DE", "US", "JP"], + from_year=2022, + to_year=2023, + ) + self.client = self.app.test_client() + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_summary_reports_current_dataset_metrics(self): + summary = summary_snapshot(self.database_path) + self.assertEqual(summary["countries"], 3) + self.assertEqual(summary["populationObservations"], 6) + self.assertEqual(summary["latestObservationYear"], 2023) + self.assertEqual(summary["missingPopulationValues"], 0) + self.assertEqual(summary["countriesWithoutPopulation"], 0) + self.assertIsNotNone(summary["lastSuccessfulIngestion"]) + + def test_data_quality_executes_named_sql_checks(self): + report = data_quality_snapshot(self.database_path) + self.assertEqual(report["status"], "passed") + self.assertEqual(report["errorViolations"], 0) + self.assertEqual(report["warningViolations"], 0) + self.assertEqual(len(report["checks"]), 10) + self.assertTrue(all(check["passed"] for check in report["checks"])) + self.assertEqual( + report["provenance"]["queryFile"], + "sql/data_quality_queries.sql", + ) + + def test_country_without_population_is_reported_as_warning(self): + with connect_database(self.database_path) as connection: + connection.execute( + """ + INSERT INTO countries ( + iso2_code, country_name, source_name, fetched_at + ) VALUES ('FR', 'France', 'test fixture', '2026-01-01T00:00:00+00:00') + """ + ) + + report = data_quality_snapshot(self.database_path) + warning = next( + check + for check in report["checks"] + if check["name"] == "countries_without_population" + ) + self.assertEqual(report["status"], "warning") + self.assertEqual(warning["violations"], 1) + self.assertFalse(warning["passed"]) + + def test_ingestion_history_records_successful_and_failed_runs(self): + with self.assertRaises(RuntimeError): + refresh_country_data( + self.database_path, + client=FailingCountryClient(), + country_codes=["DE"], + from_year=2022, + to_year=2023, + ) + + runs = list_ingestion_runs(self.database_path, limit=10) + self.assertEqual([run["status"] for run in runs[:2]], ["failed", "success"]) + self.assertIn("Source unavailable", runs[0]["errorMessage"]) + + def test_reporting_endpoints_return_documented_shapes(self): + summary_response = self.client.get("/api/v1/summary") + self.assertEqual(summary_response.status_code, 200) + self.assertEqual(summary_response.get_json()["data"]["countries"], 3) + + quality_response = self.client.get("/api/v1/data-quality") + self.assertEqual(quality_response.status_code, 200) + self.assertEqual(quality_response.get_json()["data"]["status"], "passed") + + runs_response = self.client.get("/api/v1/ingestion-runs?limit=1") + self.assertEqual(runs_response.status_code, 200) + self.assertEqual(runs_response.get_json()["meta"]["count"], 1) + self.assertEqual(runs_response.get_json()["data"][0]["status"], "success") + + def test_invalid_ingestion_limit_uses_error_contract(self): + response = self.client.get("/api/v1/ingestion-runs?limit=0") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.get_json()["error"]["code"], + "invalid_query_parameter", + ) + + +if __name__ == "__main__": + unittest.main()