3. Manual validation, layer by layer
Section 2 described the three-layer pattern. This page builds it. You'll write three validator functions against the Chapter 8 Weather Dashboard API, chain them into a fail-fast pipeline, and drop the pipeline into the dashboard's fetch method so nothing downstream sees data that hasn't been validated.
Every validator on this page shares the same signature: it receives data and returns (is_valid, error_message). A consistent interface makes them easy to chain, easy to test, and easy to recognise in a codebase -- whenever you see a function returning that two-tuple, you know what it does.
Layer 1: structural validation
Structural validation checks the response has the right shape. It runs first so every later check can reach the fields it needs without worrying about KeyError or TypeError. If the Weather Dashboard receives a list instead of a dictionary, or a response missing the whole current block, structural validation is what catches it.
def validate_weather_structure(data):
"""
Layer 1: validate weather response structure.
Returns (is_valid, error_message).
"""
if not isinstance(data, dict):
return False, "Weather data must be a dictionary"
if "current" not in data:
return False, "Weather data missing 'current' section"
if not isinstance(data["current"], dict):
return False, "'current' section must be a dictionary"
if "temperature_2m" not in data["current"]:
return False, "Missing required temperature_2m field"
if "current_units" not in data:
# Degraded but usable, not a hard failure.
print("Warning: missing units information")
return True, None
Five checks: the root is a dictionary, the required current key is present, the nested value is itself a dictionary, the required temperature_2m field is present inside it, and the optional current_units block produces a warning if missing rather than a rejection. After this validator returns (True, None), the rest of the pipeline can call data["current"]["temperature_2m"] without wrapping it in a try/except.
Save the validator, then try it against both a valid and an invalid response:
from validators import validate_weather_structure
print(validate_weather_structure({"current": {"temperature_2m": 22.5}, "current_units": {}}))
print(validate_weather_structure({"current": {}, "current_units": {}}))
print(validate_weather_structure({"readings": {}}))
print(validate_weather_structure("not a dict"))
$ python test_structure.py
(True, None)
(False, 'Missing required temperature_2m field')
(False, "Weather data missing 'current' section")
(False, 'Weather data must be a dictionary')
Layer 2: content validation
Content validation checks individual field values are realistic. Temperature inside the sensor range, humidity between 0 and 100, wind speed non-negative. This is the layer that catches the -999 placeholder and the string "N/A" where a number belongs. It assumes structural validation already succeeded, so it can reach fields directly.
def validate_weather_content(current_data):
"""
Layer 2: validate weather content for realistic values.
Returns (is_valid, error_message).
"""
temp = current_data["temperature_2m"]
if temp is None:
return False, "Temperature cannot be null"
try:
temp_float = float(temp)
if temp_float < -100 or temp_float > 60:
return False, f"Unrealistic temperature: {temp_float}°C"
except (ValueError, TypeError):
return False, f"Temperature must be numeric, got: {temp}"
# Optional fields: validated only if present.
if "relative_humidity_2m" in current_data:
humidity = current_data["relative_humidity_2m"]
if humidity is not None:
try:
humidity_float = float(humidity)
if humidity_float < 0 or humidity_float > 100:
return False, f"Invalid humidity: {humidity_float}%"
except (ValueError, TypeError):
return False, f"Humidity must be numeric, got: {humidity}"
if "wind_speed_10m" in current_data:
wind = current_data["wind_speed_10m"]
if wind is not None:
try:
wind_float = float(wind)
if wind_float < 0:
return False, f"Wind speed cannot be negative: {wind_float}"
if wind_float > 200:
return False, f"Unrealistic wind speed: {wind_float} km/h"
except (ValueError, TypeError):
return False, f"Wind speed must be numeric, got: {wind}"
return True, None
Required versus optional fields
Temperature is required, so Layer 1 already rejected missing-field cases. Layer 2 rejects null, non-numeric, or out-of-range temperatures. Humidity and wind speed are optional: the validator skips them when absent and applies the full range check when present. The pattern (critical fields fail hard, optional fields either validate or gracefully degrade) works because Layer 1 guarantees the required field exists, and the in checks gate each optional-field access.
Layer 3: business-rule validation
Business rules validate cross-field logic and domain-specific constraints. Individual values may pass the previous layers while the combination remains impossible: snow codes at warm temperatures, apparent temperatures wildly divergent from actuals. The layer runs last because cross-field comparisons depend on the participating values being numeric and in range. The previous layers do most of that work before this function runs.
def validate_weather_business_rules(current_data):
"""
Layer 3: validate weather business rules and logical consistency.
Returns (is_valid, error_message).
"""
temp = current_data.get("temperature_2m")
weather_code = current_data.get("weather_code")
# Snow conditions should match temperature.
if temp is not None and weather_code is not None:
try:
temp_float = float(temp)
code_int = int(weather_code)
# WMO codes 71, 73, 75 are snow at varying intensities.
if code_int in [71, 73, 75] and temp_float > 5:
return False, f"Snow at {temp_float}°C is unlikely"
except (ValueError, TypeError):
pass
# "Feels like" should track actual temperature within reason.
apparent_temp = current_data.get("apparent_temperature")
if temp is not None and apparent_temp is not None:
try:
temp_float = float(temp)
apparent_float = float(apparent_temp)
if abs(apparent_float - temp_float) > 20:
return False, (
f"'Feels like' {apparent_float}°C too different "
f"from actual {temp_float}°C"
)
except (ValueError, TypeError):
pass
return True, None
Temperature is already known good from Layer 2, so the cross-field comparisons can focus on relationships rather than types. This manual version still converts temp again as a belt-and-braces guard, but the meaningful catches are int(weather_code) and float(apparent_temperature), because Layer 2 does not validate those optional fields.
The complete pipeline
The three validators chain together into a fail-fast pipeline. Each layer runs only if the previous one succeeded; the first failure is the final answer. That's the whole function:
def validate_weather_data(data):
"""
Complete validation pipeline: structure -> content -> business rules.
Returns (is_valid, error_message).
"""
valid, error = validate_weather_structure(data)
if not valid:
return False, f"Structure validation failed: {error}"
# Safe to reach into data["current"] because structure validated.
current = data["current"]
valid, error = validate_weather_content(current)
if not valid:
return False, f"Content validation failed: {error}"
valid, error = validate_weather_business_rules(current)
if not valid:
return False, f"Business rule validation failed: {error}"
return True, None
Run the pipeline against valid data and a sensor-failure response to see both paths:
from validators import validate_weather_data
good_data = {
"current": {
"temperature_2m": 22.5,
"relative_humidity_2m": 65,
"wind_speed_10m": 12.3,
"weather_code": 0,
"apparent_temperature": 21.8
},
"current_units": {"temperature_2m": "°C"}
}
bad_data = {
"current": {
"temperature_2m": -999,
"relative_humidity_2m": 65
},
"current_units": {}
}
snow_at_15 = {
"current": {"temperature_2m": 15.0, "weather_code": 71},
"current_units": {}
}
print(validate_weather_data(good_data))
print(validate_weather_data(bad_data))
print(validate_weather_data(snow_at_15))
$ python test_pipeline.py
(True, None)
(False, 'Content validation failed: Unrealistic temperature: -999.0°C')
(False, 'Business rule validation failed: Snow at 15.0°C is unlikely')
Fail-fast semantics
The pipeline stops at the first failure. If structural validation rejects the data, content and business-rule validators never run; if content validation fails, business-rule validation is skipped. That's deliberate: there is no useful business-rule check you can run on a response that failed structure, and cascading the error prefix ("Structure validation failed:" / "Content validation failed:" / "Business rule validation failed:") tells a caller exactly which layer caught the problem without needing an error code or exception type.
Dropping the pipeline into the Weather Dashboard
Validation earns its keep at boundaries. The Chapter 8 Weather Dashboard fetches from Open-Meteo and hands the parsed JSON straight to the display function. Inserting the validation pipeline between the HTTP call and the display means every path downstream can trust the data -- no scattered defensive checks, no "what if temperature is a string?" logic in the formatter.
import requests
from validators import validate_weather_data
class ValidatedWeatherDashboard:
"""Weather dashboard with three-layer validation at the boundary."""
def __init__(self):
self.weather_url = "https://api.open-meteo.com/v1/forecast"
def get_weather_data(self, latitude, longitude):
"""Fetch and validate weather data. Returns dict or None."""
params = {
"latitude": latitude,
"longitude": longitude,
"current": [
"temperature_2m", "relative_humidity_2m",
"wind_speed_10m", "weather_code", "apparent_temperature",
],
"timezone": "auto",
}
try:
response = requests.get(self.weather_url, params=params, timeout=15)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None
valid, error = validate_weather_data(data)
if not valid:
print(f"Data validation failed: {error}")
return None
print("Data validated successfully")
return data
def display_weather(self, data):
"""Display validated weather data. No defensive checks needed."""
if not data:
return
current = data["current"]
temp = current["temperature_2m"]
humidity = current.get("relative_humidity_2m", "N/A")
wind = current.get("wind_speed_10m", "N/A")
print(f"Temperature: {temp}°C")
print(f"Humidity: {humidity}%")
print(f"Wind speed: {wind} km/h")
if __name__ == "__main__":
dashboard = ValidatedWeatherDashboard()
# Dublin coordinates
weather_data = dashboard.get_weather_data(53.3498, -6.2603)
dashboard.display_weather(weather_data)
$ python validated_dashboard.py
Data validated successfully
Temperature: 9.8°C
Humidity: 78%
Wind speed: 14.6 km/h
Validate once, trust everywhere
The display_weather method reaches directly into current["temperature_2m"] with no try/except and no range check. It can do that because the validation pipeline has already confirmed the field exists, is numeric, and is in range. Humidity and wind use .get() because they're optional and may legitimately be absent; neither needs defensive type or range checks downstream. Validation at the boundary is what lets downstream code stay simple -- the contract is enforced once, at the entry point, and the rest of the system works against a known-good shape.
This manual implementation works and ships. It is also roughly 100 lines of near-repetitive if / try / return code that grows linearly with every new field: about 60 lines encode structure and content, while the rest is the cross-field rules and the orchestrator that chains the layers. Section 4 replaces those 60 with a declarative JSON Schema, keeping the business-rule validator intact for section 5.