From 4d2b179d1b8480468617fcea9c434d7a27de426f Mon Sep 17 00:00:00 2001 From: Davor Runje Date: Mon, 17 Jun 2024 12:50:10 +0200 Subject: [PATCH] descriptions added to OpenAPI spec (#16) Co-authored-by: Kumaran Rajendhiran --- pyproject.toml | 4 +- tests/app/test_app.py | 192 +++++++++++++++++++++++++++++++++++----- weatherapi/__about__.py | 2 +- weatherapi/__init__.py | 4 +- weatherapi/app.py | 20 +++-- 5 files changed, 190 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9aa9d84..6b9411c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,9 +64,9 @@ lint = [ "types-Pygments", "types-docutils", "mypy==1.10.0", - "ruff==0.4.8", + "ruff==0.4.9", "pyupgrade-directories==0.3.0", - "bandit==1.7.8", + "bandit==1.7.9", "semgrep==1.75.0", "pytest-mypy-plugins==3.1.2", ] diff --git a/tests/app/test_app.py b/tests/app/test_app.py index 405fce7..b5a8bd8 100644 --- a/tests/app/test_app.py +++ b/tests/app/test_app.py @@ -2,31 +2,177 @@ from fastapi.testclient import TestClient +from weatherapi import __version__ as version from weatherapi.app import app client = TestClient(app) -def test_weather_route() -> None: - response = client.get("/?city=Chennai") - assert response.status_code == 200 - resp_json = response.json() - assert resp_json.get("city") == "Chennai" - assert resp_json.get("temperature") > 0 - - assert len(resp_json.get("daily_forecasts")) > 0 - daily_forecasts = resp_json.get("daily_forecasts") - assert isinstance(daily_forecasts, list) - - first_daily_forecast = daily_forecasts[0] - assert ( - first_daily_forecast.get("forecast_date") == datetime.date.today().isoformat() - ) - assert first_daily_forecast.get("temperature") > 0 - assert len(first_daily_forecast.get("hourly_forecasts")) > 0 - - first_hourly_forecast = first_daily_forecast.get("hourly_forecasts")[0] - assert isinstance(first_hourly_forecast, dict) - assert first_hourly_forecast.get("forecast_time") is not None - assert first_hourly_forecast.get("temperature") > 0 # type: ignore - assert first_hourly_forecast.get("description") is not None +class TestRoutes: + def test_weather_route(self) -> None: + response = client.get("/?city=Chennai") + assert response.status_code == 200 + resp_json = response.json() + assert resp_json.get("city") == "Chennai" + assert resp_json.get("temperature") > 0 + + assert len(resp_json.get("daily_forecasts")) > 0 + daily_forecasts = resp_json.get("daily_forecasts") + assert isinstance(daily_forecasts, list) + + first_daily_forecast = daily_forecasts[0] + assert ( + first_daily_forecast.get("forecast_date") + == datetime.date.today().isoformat() + ) + assert first_daily_forecast.get("temperature") > 0 + assert len(first_daily_forecast.get("hourly_forecasts")) > 0 + + first_hourly_forecast = first_daily_forecast.get("hourly_forecasts")[0] + assert isinstance(first_hourly_forecast, dict) + assert first_hourly_forecast.get("forecast_time") is not None + assert first_hourly_forecast.get("temperature") > 0 # type: ignore + assert first_hourly_forecast.get("description") is not None + + def test_openapi(self) -> None: + expected = { + "openapi": "3.1.0", + "info": {"title": "WeatherAPI", "version": version}, + "servers": [ + {"url": "http://localhost:8000", "description": "Weather app server"} + ], + "paths": { + "/": { + "get": { + "summary": "Get Weather", + "operationId": "get_weather__get", + "description": "Get weather forecast for a given city", + "parameters": [ + { + "name": "city", + "in": "query", + "description": "city for which forecast is requested", + "required": True, + "schema": { + "type": "string", + "title": "City", + "description": "city for which forecast is requested", + }, + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Weather" + } + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "DailyForecast": { + "properties": { + "forecast_date": { + "type": "string", + "format": "date", + "title": "Forecast Date", + }, + "temperature": {"type": "integer", "title": "Temperature"}, + "hourly_forecasts": { + "items": { + "$ref": "#/components/schemas/HourlyForecast" + }, + "type": "array", + "title": "Hourly Forecasts", + }, + }, + "type": "object", + "required": [ + "forecast_date", + "temperature", + "hourly_forecasts", + ], + "title": "DailyForecast", + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail", + } + }, + "type": "object", + "title": "HTTPValidationError", + }, + "HourlyForecast": { + "properties": { + "forecast_time": { + "type": "string", + "format": "time", + "title": "Forecast Time", + }, + "temperature": {"type": "integer", "title": "Temperature"}, + "description": {"type": "string", "title": "Description"}, + }, + "type": "object", + "required": ["forecast_time", "temperature", "description"], + "title": "HourlyForecast", + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + "type": "array", + "title": "Location", + }, + "msg": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Error Type"}, + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError", + }, + "Weather": { + "properties": { + "city": {"type": "string", "title": "City"}, + "temperature": {"type": "integer", "title": "Temperature"}, + "daily_forecasts": { + "items": {"$ref": "#/components/schemas/DailyForecast"}, + "type": "array", + "title": "Daily Forecasts", + }, + }, + "type": "object", + "required": ["city", "temperature", "daily_forecasts"], + "title": "Weather", + }, + } + }, + } + response = client.get("/openapi.json") + assert response.status_code == 200 + resp_json = response.json() + + assert resp_json == expected diff --git a/weatherapi/__about__.py b/weatherapi/__about__.py index 2045314..a9fae82 100644 --- a/weatherapi/__about__.py +++ b/weatherapi/__about__.py @@ -1,3 +1,3 @@ """A simple weather API made for usage with FastAgency.""" -__version__ = "0.0.0dev0" +__version__ = "0.1.0" diff --git a/weatherapi/__init__.py b/weatherapi/__init__.py index d2200ae..43eb614 100644 --- a/weatherapi/__init__.py +++ b/weatherapi/__init__.py @@ -1,3 +1,5 @@ """A simple weather API made for usage with FastAgency.""" -from .__about__ import __version__ # noqa: F401 +from .__about__ import __version__ + +__all__ = ["__version__"] diff --git a/weatherapi/app.py b/weatherapi/app.py index 438c24f..7ed9c8d 100644 --- a/weatherapi/app.py +++ b/weatherapi/app.py @@ -1,12 +1,16 @@ import datetime import logging from os import environ -from typing import List +from typing import Annotated, List import python_weather -from fastapi import FastAPI +from fastapi import FastAPI, Query from pydantic import BaseModel +from . import __version__ + +__all__ = ["app"] + logging.basicConfig(level=logging.INFO) host = environ.get("DOMAIN", "localhost") @@ -16,7 +20,11 @@ f"{protocol}://{host}:{port}" if host == "localhost" else f"{protocol}://{host}" ) -app = FastAPI(servers=[{"url": base_url, "description": "Weather app server"}]) +app = FastAPI( + servers=[{"url": base_url, "description": "Weather app server"}], + version=__version__, + title="WeatherAPI", +) class HourlyForecast(BaseModel): @@ -37,8 +45,10 @@ class Weather(BaseModel): daily_forecasts: List[DailyForecast] -@app.get("/") -async def get_weather(city: str) -> Weather: +@app.get("/", description="Get weather forecast for a given city") +async def get_weather( + city: Annotated[str, Query(description="city for which forecast is requested")], +) -> Weather: async with python_weather.Client(unit=python_weather.METRIC) as client: # fetch a weather forecast from a city weather = await client.get(city)