From b785178f376d1b8d050991a5cf869e4ed398a1b0 Mon Sep 17 00:00:00 2001 From: Mplan Date: Sat, 20 Jun 2026 12:44:32 +0800 Subject: [PATCH] style:use restful api --- README.md | 55 ++++++++++++++++---- app/database.py | 78 ++++++++++++++++++++++++++++ app/main.py | 134 +++++++++++++++++++++++++++++++++++++----------- app/models.py | 28 ++++++++-- 4 files changed, 248 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 22557ae..0232884 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,36 @@ uv run uvicorn app.main:app --reload ## API 端点 -### POST `/data` — 接收气象数据 +### GET `/stations` — 列出所有气象站 + +获取所有已知气象站的列表(从观测数据中派生)。 + +成功响应(200): + +```json +{ + "stations": [ + { + "name": "北京站", + "observation_count": 156, + "latest_observation_at": "2026-06-19T11:45:00.654321+00:00" + }, + { + "name": "上海站", + "observation_count": 89, + "latest_observation_at": "2026-06-19T10:30:00.123456+00:00" + } + ], + "count": 2 +} +``` + +### POST `/stations/{station_name}/observations` — 接收气象数据 请求体(JSON): ```json { - "station_name": "北京站", "temperature": 26.5, "pressure": 1013.2, "relative_humidity": 68.0 @@ -63,11 +86,12 @@ uv run uvicorn app.main:app --reload | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `station_name` | string | 是 | 气象站名称 | | `temperature` | float | 否 | 温度(摄氏度) | | `pressure` | float | 否 | 气压(hPa) | | `relative_humidity` | float | 否 | 相对湿度(%,0-100) | +> 气象站名称从 URL 路径中获取,不再出现在请求体中。 + 成功响应(201): ```json @@ -83,12 +107,12 @@ uv run uvicorn app.main:app --reload `received_at` 由服务端在收到数据时自动记录。 -### GET `/data/{station_name}` — 查询最新数据 +### GET `/stations/{station_name}/observations/latest` — 查询最新数据 -获取指定气象站的最新一条记录。站名支持中文,直接放在 URL 路径中即可: +获取指定气象站的最新一条记录: ``` -GET /data/北京站 +GET /stations/北京站/observations/latest ``` 成功响应(200): @@ -106,35 +130,44 @@ GET /data/北京站 未找到数据时返回 404。 -### GET `/data/{station_name}/history` — 查询历史数据 +### GET `/stations/{station_name}/observations` — 查询历史数据 获取指定时间范围内的所有记录。支持两种查询方式: **按最近 N 小时查询:** ``` -GET /data/北京站/history?hours=24 +GET /stations/北京站/observations?hours=24 ``` **按自定义时间范围查询:** ``` -GET /data/北京站/history?start=2026-06-19T00:00:00&end=2026-06-19T12:00:00 +GET /stations/北京站/observations?start=2026-06-19T00:00:00&end=2026-06-19T12:00:00 +``` + +**分页查询:** + +``` +GET /stations/北京站/observations?limit=50&offset=0 ``` 参数说明: | 参数 | 类型 | 说明 | |------|------|------| -| `hours` | int | 从现在往前推 N 小时。如果同时指定了 `start`,则仅影响 `start` | +| `hours` | int | 从现在往前推 N 小时。如果同时指定了 `start`,则覆盖 `start` | | `start` | datetime | 时间范围起点(ISO 8601 格式,UTC),不传则不限制 | | `end` | datetime | 时间范围终点(ISO 8601 格式,UTC),不传则默认到当前时刻 | +| `limit` | int | 返回的最大记录数 | +| `offset` | int | 跳过的记录数(用于分页),默认 0 | 成功响应(200): ```json { "station_name": "北京站", + "count": 2, "records": [ { "id": 42, @@ -156,7 +189,7 @@ GET /data/北京站/history?start=2026-06-19T00:00:00&end=2026-06-19T12:00:00 } ``` -未找到匹配数据时返回空列表。 +未找到匹配数据时返回空列表,气象站不存在时返回 404。 --- diff --git a/app/database.py b/app/database.py index 1acdf0c..896075a 100644 --- a/app/database.py +++ b/app/database.py @@ -123,3 +123,81 @@ async def get_history_by_station( *params, ) return [dict(r) for r in rows] + + +async def list_all_stations() -> list[dict]: + """Return all distinct station names with observation counts and latest timestamps. + + Derives stations from weather_data since there is no separate stations table. + """ + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT + station_name AS name, + COUNT(*)::int AS observation_count, + MAX(received_at) AS latest_observation_at + FROM weather_data + GROUP BY station_name + ORDER BY station_name + """) + return [dict(r) for r in rows] + + +async def get_station_exists(station_name: str) -> bool: + """Check whether a station has any observations.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT 1 FROM weather_data WHERE station_name = $1 LIMIT 1", + station_name, + ) + return row is not None + + +async def get_history_by_station_paginated( + station_name: str, + start_time: datetime | None = None, + end_time: datetime | None = None, + limit: int | None = None, + offset: int = 0, +) -> list[dict]: + """Return observations for a station within the given time range, with pagination. + + Returns an empty list if no records match. + """ + pool = await get_pool() + async with pool.acquire() as conn: + conditions = ["station_name = $1"] + params: list = [station_name] + idx = 2 + + if start_time is not None: + conditions.append(f"received_at >= ${idx}") + params.append(start_time) + idx += 1 + + if end_time is not None: + conditions.append(f"received_at <= ${idx}") + params.append(end_time) + idx += 1 + + where_clause = " AND ".join(conditions) + + query = f""" + SELECT id, station_name, temperature, pressure, relative_humidity, received_at + FROM weather_data + WHERE {where_clause} + ORDER BY received_at DESC + """ + + if limit is not None: + query += f" LIMIT ${idx}" + params.append(limit) + idx += 1 + + query += f" OFFSET ${idx}" + params.append(offset) + + rows = await conn.fetch(query, *params) + return [dict(r) for r in rows] diff --git a/app/main.py b/app/main.py index 6720007..21fb4f1 100644 --- a/app/main.py +++ b/app/main.py @@ -6,12 +6,20 @@ from fastapi import FastAPI, HTTPException, Query from app.database import ( close_pool, - get_history_by_station, + get_history_by_station_paginated, get_latest_by_station, + get_station_exists, init_db, insert_weather_data, + list_all_stations, +) +from app.models import ( + ObservationListResponse, + StationInfo, + StationListResponse, + WeatherDataIn, + WeatherRecordOut, ) -from app.models import WeatherDataIn, WeatherHistoryResponse, WeatherRecordOut @asynccontextmanager @@ -25,77 +33,141 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI( title="Weather Data API", description="Receive and query weather station observations", - version="0.1.0", + version="0.2.0", lifespan=lifespan, ) -@app.post("/data", response_model=WeatherRecordOut, status_code=201) -async def post_weather_data(payload: WeatherDataIn) -> WeatherRecordOut: - """Ingest a weather observation from a station. +# --------------------------------------------------------------------------- +# Stations +# --------------------------------------------------------------------------- - The server records the time it was received (`received_at`). - """ - record = await insert_weather_data( - station_name=payload.station_name, - temperature=payload.temperature, - pressure=payload.pressure, - relative_humidity=payload.relative_humidity, + +@app.get("/stations", response_model=StationListResponse) +async def list_stations() -> StationListResponse: + """List all known weather stations derived from observation data.""" + stations = await list_all_stations() + return StationListResponse( + stations=[StationInfo(**s) for s in stations], + count=len(stations), ) - return WeatherRecordOut(**record) -@app.get("/data/{station_name}", response_model=WeatherRecordOut) -async def get_latest_weather(station_name: str) -> WeatherRecordOut: - """Return the most recent observation for the given station.""" +# --------------------------------------------------------------------------- +# Observations +# --------------------------------------------------------------------------- + + +@app.get( + "/stations/{station_name}/observations/latest", + response_model=WeatherRecordOut, +) +async def get_latest_observation(station_name: str) -> WeatherRecordOut: + """Return the most recent observation for a station. + + Returns 404 if the station has no observations. + """ record = await get_latest_by_station(station_name) if record is None: raise HTTPException( status_code=404, - detail=f"No data found for station '{station_name}'", + detail=f"No observations found for station '{station_name}'", ) return WeatherRecordOut(**record) -@app.get("/data/{station_name}/history", response_model=WeatherHistoryResponse) -async def get_weather_history( +@app.get( + "/stations/{station_name}/observations", + response_model=ObservationListResponse, +) +async def list_observations( station_name: str, hours: int | None = Query( None, gt=0, description="Look back this many hours from now (e.g. 24 = last 24 hours). " - "Ignored if both start and end are provided.", + "Overrides start if both are provided.", ), start: datetime | None = Query( None, - description="Start of the time range (ISO 8601, UTC). Defaults to unbounded.", + description="Start of the time range (ISO 8601, UTC, inclusive). " + "Defaults to unbounded.", ), end: datetime | None = Query( None, - description="End of the time range (ISO 8601, UTC). Defaults to now.", + description="End of the time range (ISO 8601, UTC, inclusive). " + "Defaults to now.", ), -) -> WeatherHistoryResponse: - """Return all observations for a station within a time range. + limit: int | None = Query( + None, + gt=0, + description="Maximum number of records to return.", + ), + offset: int = Query( + 0, + ge=0, + description="Number of records to skip (for pagination).", + ), +) -> ObservationListResponse: + """List observations for a station with optional time-range filtering and pagination. - Use ``hours`` alone to get the last *N* hours of data. - Use ``start`` and/or ``end`` for a custom range. - - Returns an empty ``records`` list when no data matches. + - Use ``limit`` and ``offset`` for pagination. + - Returns 404 if the station has no observations at all. + - Returns 200 with an empty ``records`` list when no data matches the filters. """ + if not await get_station_exists(station_name): + raise HTTPException( + status_code=404, + detail=f"Station '{station_name}' not found", + ) + now = datetime.now(timezone.utc) - # Resolve start / end from the query parameters resolved_start: datetime | None = start resolved_end: datetime | None = end if hours is not None: - # hours takes precedence for start, but explicit end still wins resolved_start = now - timedelta(hours=hours) if end is None: resolved_end = now - # Default end to now if neither hours nor end was given and start was specified if resolved_start is not None and resolved_end is None: resolved_end = now - records = await get_history_by_station(station_name, resolved_start, resolved_end) - return WeatherHistoryResponse(station_name=station_name, records=records) + records = await get_history_by_station_paginated( + station_name, + resolved_start, + resolved_end, + limit=limit, + offset=offset, + ) + return ObservationListResponse( + station_name=station_name, + count=len(records), + records=[WeatherRecordOut(**r) for r in records], + ) + + +@app.post( + "/stations/{station_name}/observations", + response_model=WeatherRecordOut, + status_code=201, +) +async def create_observation( + station_name: str, + payload: WeatherDataIn, +) -> WeatherRecordOut: + """Ingest a weather observation for a station. + + The station is identified by the URL path. Stations are created + implicitly when their first observation is posted. + """ + record = await insert_weather_data( + station_name=station_name, + temperature=payload.temperature, + pressure=payload.pressure, + relative_humidity=payload.relative_humidity, + ) + return WeatherRecordOut(**record) diff --git a/app/models.py b/app/models.py index 054eb71..9c5ccb9 100644 --- a/app/models.py +++ b/app/models.py @@ -4,9 +4,11 @@ from pydantic import BaseModel, Field class WeatherDataIn(BaseModel): - """Payload accepted by POST /data.""" + """Payload accepted by POST /stations/{station_name}/observations. + + station_name is taken from the URL path, not the request body. + """ - station_name: str = Field(..., description="Name of the weather station") temperature: float | None = Field(None, description="Temperature in Celsius") pressure: float | None = Field(None, description="Atmospheric pressure in hPa") relative_humidity: float | None = Field( @@ -15,7 +17,7 @@ class WeatherDataIn(BaseModel): class WeatherRecordOut(BaseModel): - """Record returned by the API (includes server-assigned fields).""" + """A single observation record (includes server-assigned fields).""" id: int station_name: str @@ -25,8 +27,24 @@ class WeatherRecordOut(BaseModel): received_at: datetime -class WeatherHistoryResponse(BaseModel): - """Wrapper for history queries — always returns a list (empty if no data).""" +class ObservationListResponse(BaseModel): + """Response for GET /stations/{station_name}/observations.""" station_name: str + count: int records: list[WeatherRecordOut] + + +class StationInfo(BaseModel): + """Summary of a known weather station.""" + + name: str + observation_count: int + latest_observation_at: datetime | None + + +class StationListResponse(BaseModel): + """Response for GET /stations.""" + + stations: list[StationInfo] + count: int