⬆ Add weather API integration and update models for two-hour forecast

This commit is contained in:
2025-05-16 21:03:19 +08:00
parent 68ad0ba8d5
commit b6297e9185
6 changed files with 442 additions and 26 deletions
+4 -3
View File
@@ -20,18 +20,19 @@ ENV UV_COMPILE_BYTECODE=1
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching
ENV UV_LINK_MODE=copy
# Copy dependency files first
COPY ./pyproject.toml ./uv.lock /app/
# Install dependencies
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project
ENV PYTHONPATH=/app
COPY ./scripts /app/scripts
COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/
COPY ./alembic.ini /app/
COPY ./app /app/app
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.routes import items, login, private, users, utils
from app.api.routes import items, login, private, users, utils, weather
from app.core.config import settings
api_router = APIRouter()
@@ -8,6 +8,7 @@ api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
api_router.include_router(weather.router)
if settings.ENVIRONMENT == "local":
+100
View File
@@ -0,0 +1,100 @@
from datetime import datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException, Query
from pydantic import ValidationError
# Import directly from models file
from app.models import WeatherApiError, WeatherResponse
router = APIRouter(prefix="/weather", tags=["weather"])
WEATHER_API_BASE_URL = "https://api-open.data.gov.sg/v2/real-time/api"
@router.get("/two-hour-forecast", response_model=WeatherResponse)
async def get_two_hour_forecast(
date: Optional[str] = Query(
None,
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
),
pagination_token: Optional[str] = Query(
None, description="Pagination token for retrieving subsequent data pages"
),
) -> Any:
"""
Retrieve the latest two hour weather forecast from data.gov.sg API.
- Updated half-hourly from NEA
- Forecasts are given for multiple areas in Singapore
"""
# Build the URL and parameters
url = f"{WEATHER_API_BASE_URL}/two-hr-forecast"
params = {}
if date:
params["date"] = date
if pagination_token:
params["paginationToken"] = pagination_token
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
# Handle error responses
if response.status_code != 200:
try:
error_data = response.json()
error = WeatherApiError(**error_data)
raise HTTPException(
status_code=response.status_code,
detail=f"{error.name}: {error.error_msg}"
)
except (ValidationError, KeyError):
raise HTTPException(
status_code=response.status_code,
detail=f"Error from weather API: {response.text}"
)
# Parse successful response
try:
data = response.json()
# Debug: Print the keys received from the API response
print(f"API response keys: {data.keys()}")
if 'items' in data.get('data', {}):
print(f"First item keys: {data['data']['items'][0].keys()}")
# Try parsing with the model
return WeatherResponse(**data)
except ValidationError as e:
# If there's a validation error, try to provide more detailed error information
print(f"Validation error parsing weather data: {str(e)}")
# If the error is specifically about missing update_timestamp vs updated_timestamp,
# try to manually fix the response data
if 'update_timestamp' in str(e) or 'updated_timestamp' in str(e):
# Try to manually fix the data structure
if 'data' in data and 'items' in data['data']:
for item in data['data']['items']:
if 'update_timestamp' in item and 'updated_timestamp' not in item:
item['updated_timestamp'] = item['update_timestamp']
# Try parsing again with the fixed data
try:
return WeatherResponse(**data)
except ValidationError:
# If it still fails, fall back to the original error
pass
# If we couldn't fix it, raise the original error
raise HTTPException(
status_code=500,
detail=f"Error parsing weather data: {str(e)}"
)
except httpx.RequestError as e:
raise HTTPException(
status_code=503,
detail=f"Error communicating with weather API: {str(e)}"
)
+97 -21
View File
@@ -1,47 +1,49 @@
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
from pydantic import EmailStr, BaseModel, Field
from sqlmodel import Field as SQLModelField, Relationship, SQLModel
# Shared properties
class UserBase(SQLModel):
email: EmailStr = Field(unique=True, index=True, max_length=255)
email: EmailStr = SQLModelField(unique=True, index=True, max_length=255)
is_active: bool = True
is_superuser: bool = False
full_name: str | None = Field(default=None, max_length=255)
full_name: str | None = SQLModelField(default=None, max_length=255)
# Properties to receive via API on creation
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=40)
password: str = SQLModelField(min_length=8, max_length=40)
class UserRegister(SQLModel):
email: EmailStr = Field(max_length=255)
password: str = Field(min_length=8, max_length=40)
full_name: str | None = Field(default=None, max_length=255)
email: EmailStr = SQLModelField(max_length=255)
password: str = SQLModelField(min_length=8, max_length=40)
full_name: str | None = SQLModelField(default=None, max_length=255)
# Properties to receive via API on update, all are optional
class UserUpdate(UserBase):
email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
password: str | None = Field(default=None, min_length=8, max_length=40)
email: EmailStr | None = SQLModelField(default=None, max_length=255) # type: ignore
password: str | None = SQLModelField(default=None, min_length=8, max_length=40)
class UserUpdateMe(SQLModel):
full_name: str | None = Field(default=None, max_length=255)
email: EmailStr | None = Field(default=None, max_length=255)
full_name: str | None = SQLModelField(default=None, max_length=255)
email: EmailStr | None = SQLModelField(default=None, max_length=255)
class UpdatePassword(SQLModel):
current_password: str = Field(min_length=8, max_length=40)
new_password: str = Field(min_length=8, max_length=40)
current_password: str = SQLModelField(min_length=8, max_length=40)
new_password: str = SQLModelField(min_length=8, max_length=40)
# Database model, database table inferred from class name
class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
id: uuid.UUID = SQLModelField(default_factory=uuid.uuid4, primary_key=True)
hashed_password: str
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
@@ -58,8 +60,8 @@ class UsersPublic(SQLModel):
# Shared properties
class ItemBase(SQLModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=255)
title: str = SQLModelField(min_length=1, max_length=255)
description: str | None = SQLModelField(default=None, max_length=255)
# Properties to receive on item creation
@@ -69,13 +71,13 @@ class ItemCreate(ItemBase):
# Properties to receive on item update
class ItemUpdate(ItemBase):
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
title: str | None = SQLModelField(default=None, min_length=1, max_length=255) # type: ignore
# Database model, database table inferred from class name
class Item(ItemBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
owner_id: uuid.UUID = Field(
id: uuid.UUID = SQLModelField(default_factory=uuid.uuid4, primary_key=True)
owner_id: uuid.UUID = SQLModelField(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
owner: User | None = Relationship(back_populates="items")
@@ -110,4 +112,78 @@ class TokenPayload(SQLModel):
class NewPassword(SQLModel):
token: str
new_password: str = Field(min_length=8, max_length=40)
new_password: str = SQLModelField(min_length=8, max_length=40)
# Weather models
class LabelLocation(BaseModel):
latitude: float
longitude: float
class Config:
extra = "ignore" # Ignore extra fields in API response
class AreaMetadata(BaseModel):
name: str
label_location: LabelLocation
class Config:
extra = "ignore" # Ignore extra fields in API response
class ForecastPeriod(BaseModel):
start: datetime
end: datetime
text: str
class Config:
extra = "ignore" # Ignore extra fields in API response
class Forecast(BaseModel):
area: str
forecast: str
class Config:
extra = "ignore" # Ignore extra fields in API response
class WeatherItem(BaseModel):
updated_timestamp: datetime = Field(alias="update_timestamp")
timestamp: datetime
valid_period: ForecastPeriod
forecasts: List[Forecast]
class Config:
extra = "ignore" # Ignore extra fields in API response
populate_by_name = True # Allow populating by field name or alias
class WeatherData(BaseModel):
area_metadata: List[AreaMetadata]
items: List[WeatherItem]
pagination_token: Optional[str] = None
class Config:
extra = "ignore" # Ignore extra fields in API response
class WeatherResponse(BaseModel):
code: int
error_msg: Optional[str] = Field(None, alias="errorMsg")
data: Optional[WeatherData] = None
class Config:
extra = "ignore" # Ignore extra fields in API response
populate_by_name = True # Allow populating by field name or alias
class WeatherApiError(BaseModel):
code: int
name: str
data: Optional[Dict[str, Any]] = None
error_msg: str = Field(alias="errorMsg")
class Config:
extra = "ignore" # Ignore extra fields in API response
+238
View File
@@ -0,0 +1,238 @@
{
"openapi": "3.0.3",
"info": {
"title": "Real-time API weather services",
"description": "Real-time API documentation of weather services",
"contact": {
"email": "feedback@data.gov.sg"
},
"version": "1.0.11"
},
"servers": [
{
"url": "https://api-open.data.gov.sg/v2/real-time/api"
}
],
"paths": {
"/two-hr-forecast": {
"get": {
"summary": "Retrieve the latest two hour weather forecast",
"description": "**[https://api-open.data.gov.sg/v2/real-time/api/two-hr-forecast](https://api-open.data.gov.sg/v2/real-time/api/two-hr-forecast)**\n\n<br/>\n\n- Updated half-hourly from NEA\n- Forecasts are given for multiple areas in Singapore\n\n<br/>\n\n- Filter for specific date or date-time by providing `date` in query parameter.\n - use YYYY-MM-DD format to retrieve all of the readings for that day\n - use YYYY-MM-DDTHH:mm:ss to retrieve the latest readings at that moment in time\n - example: `?date=2024-07-16` or `?date=2024-07-16T23:59:00`\n\n<br/>\n\n- Possible values for forecast include:\n - Fair\n - Fair (Day)\n - Fair (Night)\n - Fair and Warm\n - Partly Cloudy\n - Partly Cloudy (Day)\n - Partly Cloudy (Night)\n - Cloudy\n - Hazy\n - Slightly Hazy\n - Windy\n - Mist\n - Fog\n - Light Rain\n - Moderate Rain\n - Heavy Rain\n - Passing Showers\n - Light Showers\n - Showers\n - Heavy Showers\n - Thundery Showers\n - Heavy Thundery Showers\n - Heavy Thundery Showers with Gusty Winds\n\n<br/>\n\n- Area metadata\n - The `area_metadata` field in the response provides longitude/latitude information for the areas. You can use that to place the forecasts on a map.\n",
"parameters": [
{
"in": "query",
"name": "date",
"description": "SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "paginationToken",
"description": "Pagination token for retrieving subsequent data pages",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "2 Hour Weather Forecast",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"description": "Response status code (always 0 for success)"
},
"errorMsg": {
"type": "string",
"description": "Error message (empty string for success)",
"example": null
},
"data": {
"type": "object",
"properties": {
"area_metadata": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the area",
"example": "Ang Mo Kio"
},
"label_location": {
"type": "object",
"description": "Provides longitude and latitude for placing readings on a map",
"properties": {
"latitude": {
"type": "number",
"example": 1.375
},
"longitude": {
"type": "number",
"example": 103.839
}
}
}
}
}
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"updated_timestamp": {
"type": "string",
"description": "Time of acquisition of data from NEA",
"example": "2024-07-17T05:05:54.000Z"
},
"timestamp": {
"type": "string",
"description": "Time forecast was issued by NEA",
"example": "2024-07-17T04:59:00.000Z"
},
"valid_period": {
"type": "object",
"description": "Period of time the forecast is valid for",
"properties": {
"start": {
"type": "string",
"example": "2024-07-16T16:30:00.000Z"
},
"end": {
"type": "string",
"example": "2024-07-16T18:30:00.000Z"
},
"text": {
"type": "string",
"example": "12.30 am to 2.30 am"
}
}
},
"forecasts": {
"type": "array",
"description": "Forecasts for various areas in Singapore",
"items": {
"type": "object",
"properties": {
"area": {
"type": "string",
"example": "Ang Mo Kio"
},
"forecast": {
"type": "string",
"enum": [
"Fair",
"Fair (Day)",
"Fair (Night)",
"Fair and Warm",
"Partly Cloudy",
"Partly Cloudy (Day)",
"Partly Cloudy (Night)",
"Cloudy",
"Hazy",
"Slightly Hazy",
"Windy",
"Mist",
"Fog",
"Light Rain",
"Moderate Rain",
"Heavy Rain",
"Passing Showers",
"Light Showers",
"Showers",
"Heavy Showers",
"Thundery Showers",
"Heavy Thundery Showers",
"Heavy Thundery Showers with Gusty Winds"
]
}
}
}
}
}
}
},
"paginationToken": {
"type": "string",
"description": "Token to retrieve next page if exists",
"example": "b2Zmc2V0PTEwMA== (you will see this token only if next page exists)"
}
}
}
}
}
}
}
},
"400": {
"description": "Invalid HTTP request body",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "number",
"example": 4
},
"name": {
"type": "string",
"example": "ERROR_PARAMS"
},
"data": {
"type": "object",
"example": null
},
"errorMsg": {
"type": "string",
"enum": [
"Invalid date format. Date format must be YYYY-MM-DD (2024-06-01) or YYYY-MM-DDTHH:mm:ss (2024-06-01T08:30:00).",
"Invalid pagination token."
]
}
}
}
}
}
},
"404": {
"description": "Weather data not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "number",
"example": 17
},
"name": {
"type": "string",
"example": "REAL_TIME_API_DATA_NOT_FOUND"
},
"data": {
"type": "object",
"example": null
},
"errorMsg": {
"type": "string",
"example": "Data not found"
}
}
}
}
}
}
}
}
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ down:
@echo "Containers stopped. Use 'make up' to start them again."
build:
podman compose --podman-build-args="--network=host" build
podman-compose build --build-arg network=host
@echo "Containers built. Use 'make up' to start them."
restart: down build up