diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a86baf9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Banua Coder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 7fa5828..6b64ece 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,38 @@ -# Pico API Go - COVID-19 Data API +# Pico API Go - Sulawesi Tengah COVID-19 Data API -A Go backend service that provides REST API endpoints for COVID-19 data in Indonesia, including national cases and province-level statistics. +A Go backend service that provides REST API endpoints for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. ## Features -- 🦠 National COVID-19 cases data with daily and cumulative statistics -- 🗺️ Province-level COVID-19 data including ODP/PDP tracking +- 🏛️ **Sulawesi Tengah focused** COVID-19 data with comprehensive statistics +- 🦠 National COVID-19 cases data for reference and context +- 🗺️ Province-level COVID-19 data with enhanced ODP/PDP grouping - 📊 R-rate (reproductive rate) data when available - 🔍 Date range filtering for all endpoints +- 📄 **Hybrid pagination system** - efficient for apps, complete for charts +- 📚 **Interactive API Documentation** - Auto-generated OpenAPI/Swagger docs +- 🎯 **Smart query parameters** - flexible data retrieval options - 🚀 Fast and efficient MySQL database integration - 🔧 Clean architecture with repository and service layers - 🛡️ CORS support for web frontend integration - 📝 Structured logging and error handling - 💾 Environment-based configuration +- 🚀 **Automatic deployment** with GitHub Actions + +## 📚 API Documentation + +### Interactive Swagger UI +- **Local development**: http://localhost:8080/swagger/index.html +- **Production**: https://pico-api.banuacoder.com/swagger/index.html + +### OpenAPI Specification +- YAML: [`docs/swagger.yaml`](docs/swagger.yaml) +- JSON: [`docs/swagger.json`](docs/swagger.json) ## API Endpoints ### Health Check -- `GET /api/v1/health` - Service health status +- `GET /api/v1/health` - Service health status and database connectivity ### National Data - `GET /api/v1/national` - Get all national cases @@ -25,11 +40,126 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon - `GET /api/v1/national/latest` - Get latest national case data ### Province Data -- `GET /api/v1/provinces` - Get all provinces -- `GET /api/v1/provinces/cases` - Get all province cases -- `GET /api/v1/provinces/cases?start_date=2020-03-01&end_date=2020-12-31` - Get province cases by date range -- `GET /api/v1/provinces/{provinceId}/cases` - Get cases for specific province -- `GET /api/v1/provinces/{provinceId}/cases?start_date=2020-03-01&end_date=2020-12-31` - Get province cases by date range +- `GET /api/v1/provinces` - Get all provinces with latest case data (default) +- `GET /api/v1/provinces?exclude_latest_case=true` - Get basic province list without case data +- `GET /api/v1/provinces/cases` - Get all province cases (paginated by default) +- `GET /api/v1/provinces/cases?all=true` - Get all province cases (complete dataset) +- `GET /api/v1/provinces/cases?limit=100&offset=50` - Get province cases with custom pagination +- `GET /api/v1/provinces/{provinceId}/cases` - Get cases for specific province (paginated) +- `GET /api/v1/provinces/{provinceId}/cases?all=true` - Get all cases for specific province + +### 🆕 Enhanced Query Parameters + +**Pagination (All province endpoints):** +- `limit` (int): Records per page (default: 50, max: 1000) +- `offset` (int): Records to skip (default: 0) +- `all` (boolean): Return complete dataset without pagination + +**Date Filtering:** +- `start_date` (YYYY-MM-DD): Filter from date +- `end_date` (YYYY-MM-DD): Filter to date + +**Province Enhancement:** +- `exclude_latest_case` (boolean): Return basic province list without case data (default includes latest case data) + +### 📄 Response Types + +**Paginated Response:** +```json +{ + "status": "success", + "data": { + "data": [...], + "pagination": { + "limit": 50, + "offset": 0, + "total": 1000, + "page": 1, + "has_next": true, + "has_prev": false + } + } +} +``` + +**Complete Data Response:** +```json +{ + "status": "success", + "data": [...] +} +``` + +## 🆕 Enhanced Data Structure + +### Grouped ODP/PDP Data + +Province case data now includes structured ODP (Person Under Observation) and PDP (Patient Under Supervision) data: + +```json +{ + "daily": { + "positive": 150, + "odp": { + "active": 5, + "finished": 20 + }, + "pdp": { + "active": 8, + "finished": 25 + } + }, + "cumulative": { + "positive": 5000, + "odp": { + "active": 50, + "finished": 750, + "total": 800 + }, + "pdp": { + "active": 20, + "finished": 580, + "total": 600 + } + } +} +``` + +## Usage Examples + +### For Web Applications (Efficient Loading) +```javascript +// Load first page (default: 50 records) +const response = await fetch('/api/v1/provinces/cases'); +const { data, pagination } = response.data; + +// Load next page +if (pagination.has_next) { + const nextPage = await fetch(`/api/v1/provinces/cases?offset=${pagination.offset + pagination.limit}`); +} +``` + +### For Charts & Analytics (Complete Dataset) +```javascript +// Get complete dataset for time series charts +const response = await fetch('/api/v1/provinces/cases?all=true&start_date=2024-01-01'); +const allData = response.data; + +// Perfect for Chart.js, D3.js, etc. +const chartData = allData.map(item => ({ + x: item.date, + y: item.cumulative.positive +})); +``` + +### For Province-Specific Analysis +```javascript +// Get all Jakarta data +const response = await fetch('/api/v1/provinces/31/cases?all=true'); + +// Get provinces with their latest statistics (default behavior) +const provincesResponse = await fetch('/api/v1/provinces'); +``` ## Setup and Installation @@ -82,6 +212,18 @@ go build -o pico-api-go cmd/main.go ./pico-api-go ``` +### Regenerating API Documentation + +After modifying handlers or adding new endpoints, regenerate the Swagger docs: + +```bash +# Install swag tool (one-time setup) +go install github.com/swaggo/swag/cmd/swag@latest + +# Generate documentation +swag init -g cmd/main.go -o ./docs +``` + ## Database Schema The API uses three main tables: @@ -126,21 +268,33 @@ git flow feature finish feature-name ### Project Structure ``` ├── cmd/ # Application entry points -│ └── main.go +│ └── main.go # Main application entry point +├── docs/ # Auto-generated API documentation +│ ├── docs.go # Generated Go documentation +│ ├── swagger.json # OpenAPI specification (JSON) +│ ├── swagger.yaml # OpenAPI specification (YAML) +│ └── README.md # Documentation guide ├── internal/ # Private application code │ ├── config/ # Configuration management │ ├── handler/ # HTTP handlers and routes │ ├── middleware/ # HTTP middleware -│ ├── models/ # Data models +│ ├── models/ # Data models and response structures │ ├── repository/ # Data access layer │ └── service/ # Business logic layer ├── pkg/ # Public packages -│ └── database/ # Database connection +│ ├── database/ # Database connection utilities +│ └── utils/ # Query parameter parsing utilities +├── test/ # Test files +│ └── integration/ # Integration tests ├── .env.example # Environment configuration template -├── .gitignore -├── go.mod -├── go.sum -└── README.md +├── .github/ # GitHub Actions workflows +├── CHANGELOG.md # Version history and changes +├── CLAUDE.md # AI assistant configuration +├── LICENSE # MIT License +├── Makefile # Build and test commands +├── go.mod # Go module definition +├── go.sum # Go module checksums +└── README.md # This file ``` ## Contributing @@ -153,4 +307,6 @@ git flow feature finish feature-name ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +Copyright (c) 2024 Banua Coder diff --git a/cmd/main.go b/cmd/main.go index 5c39988..3e48165 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,3 +1,33 @@ +// Package main provides the entry point for the Sulawesi Tengah COVID-19 Data API +// +// @title Sulawesi Tengah COVID-19 Data API +// @version 2.0.2 +// @description A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. Features enhanced ODP/PDP grouping and hybrid pagination. +// @termsOfService http://swagger.io/terms/ +// +// @contact.name API Support +// @contact.url https://github.com/banua-coder/pico-api-go +// @contact.email support@banuacoder.com +// +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT +// +// @host pico-api.banuacoder.com +// @BasePath /api/v1 +// +// @schemes https http +// +// @tag.name health +// @tag.description Health check operations +// +// @tag.name national +// @tag.description National COVID-19 case operations (for context) +// +// @tag.name provinces +// @tag.description Province information and COVID-19 case operations (focus on Sulawesi Tengah) +// +// @tag.name province-cases +// @tag.description Province-level COVID-19 case data with pagination support package main import ( @@ -11,6 +41,7 @@ import ( "github.com/banua-coder/pico-api-go/internal/repository" "github.com/banua-coder/pico-api-go/internal/service" "github.com/banua-coder/pico-api-go/pkg/database" + _ "github.com/banua-coder/pico-api-go/docs" // Import generated docs ) func main() { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7e9cf99 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,64 @@ +# API Documentation + +This directory contains auto-generated OpenAPI/Swagger documentation for the Sulawesi Tengah COVID-19 Data API. + +## Files + +- `swagger.yaml` - OpenAPI 3.0 specification in YAML format +- `swagger.json` - OpenAPI 3.0 specification in JSON format +- `docs.go` - Generated Go file containing the documentation + +## Usage + +### Interactive Documentation + +When the API server is running, you can access the interactive Swagger UI at: +- **Local development**: http://localhost:8080/swagger/index.html +- **Production**: https://pico-api.banuacoder.com/swagger/index.html + +### Swagger Specification Files + +You can also use the specification files directly with various tools: + +```bash +# Validate the OpenAPI spec +swagger-codegen validate -i docs/swagger.yaml + +# Generate client SDKs +swagger-codegen generate -i docs/swagger.yaml -l javascript -o clients/js +swagger-codegen generate -i docs/swagger.yaml -l python -o clients/python + +# Import into Postman, Insomnia, or other API tools +# Use the swagger.json or swagger.yaml file +``` + +## Regenerating Documentation + +To regenerate the documentation after code changes: + +```bash +swag init -g cmd/main.go -o ./docs +``` + +This will update all files in this directory based on the Go code annotations. + +## Key Features Documented + +- 🏥 **Health Check** - API status and database connectivity +- 🏛️ **Sulawesi Tengah Focus** - Primary COVID-19 data for Central Sulawesi +- 🇮🇩 **National Data** - COVID-19 statistics for Indonesia (reference context) +- 🗺️ **Province Data** - Provincial COVID-19 information with latest case data by default +- 📊 **Province Cases** - Detailed case data with hybrid pagination support +- 📄 **Pagination** - Comprehensive pagination metadata and flexible data retrieval +- 🏷️ **Enhanced Data Structure** - Grouped ODP/PDP fields with proper daily/cumulative separation + +## Response Models + +All response models include proper JSON schema definitions with: +- Type validation +- Required field specifications +- Example values +- Field descriptions +- Nested object relationships + +This makes it easy to generate client code, validate API responses, and understand the data structure. \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..f226601 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,788 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/banua-coder/pico-api-go", + "email": "support@banuacoder.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/": { + "get": { + "description": "Get a list of all available API endpoints with descriptions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "API endpoint index", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, + "/health": { + "get": { + "description": "Check API health status and database connectivity", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "API is healthy", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + }, + "503": { + "description": "API is degraded (database issues)", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, + "/national": { + "get": { + "description": "Retrieve national COVID-19 cases data with optional date range filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get national COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/national/latest": { + "get": { + "description": "Retrieve the most recent national COVID-19 case data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get latest national COVID-19 case", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces": { + "get": { + "description": "Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "provinces" + ], + "summary": "Get provinces with COVID-19 data", + "parameters": [ + { + "type": "boolean", + "description": "Exclude latest case data (default: false)", + "name": "exclude_latest_case", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Basic province list when exclude_latest_case=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Province" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/{provinceId}/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Province ID (e.g., '31' for Jakarta)", + "name": "provinceId", + "in": "path" + }, + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + } + }, + "definitions": { + "handler.Response": { + "type": "object", + "properties": { + "data": {}, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "models.CasePercentages": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "deceased": { + "type": "number" + }, + "recovered": { + "type": "number" + } + } + }, + "models.CumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.DailySupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.NationalCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.CumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.DailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "statistics": { + "$ref": "#/definitions/models.NationalCaseStatistics" + } + } + }, + "models.NationalCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "models.PaginatedResponse": { + "type": "object", + "properties": { + "data": {}, + "pagination": { + "$ref": "#/definitions/models.PaginationMeta" + } + } + }, + "models.PaginationMeta": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "has_prev": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "models.Province": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "models.ProvinceCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.ProvinceCumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.ProvinceDailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "province": { + "$ref": "#/definitions/models.Province" + }, + "statistics": { + "$ref": "#/definitions/models.ProvinceCaseStatistics" + } + } + }, + "models.ProvinceCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ProvinceCumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.ObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.SupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceDailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.DailyObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.DailySupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceWithLatestCase": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "latest_case": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + }, + "name": { + "type": "string" + } + } + }, + "models.ReproductionRate": { + "type": "object", + "properties": { + "lower_bound": { + "type": "number" + }, + "upper_bound": { + "type": "number" + }, + "value": { + "type": "number" + } + } + }, + "models.SupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + }, + "tags": [ + { + "description": "Health check operations", + "name": "health" + }, + { + "description": "National COVID-19 case operations (for context)", + "name": "national" + }, + { + "description": "Province information and COVID-19 case operations (focus on Sulawesi Tengah)", + "name": "provinces" + }, + { + "description": "Province-level COVID-19 case data with pagination support", + "name": "province-cases" + } + ] +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "2.0.2", + Host: "pico-api.banuacoder.com", + BasePath: "/api/v1", + Schemes: []string{"https", "http"}, + Title: "Sulawesi Tengah COVID-19 Data API", + Description: "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. Features enhanced ODP/PDP grouping and hybrid pagination.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..66fb437 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,768 @@ +{ + "schemes": [ + "https", + "http" + ], + "swagger": "2.0", + "info": { + "description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. Features enhanced ODP/PDP grouping and hybrid pagination.", + "title": "Sulawesi Tengah COVID-19 Data API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/banua-coder/pico-api-go", + "email": "support@banuacoder.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "2.0.2" + }, + "host": "pico-api.banuacoder.com", + "basePath": "/api/v1", + "paths": { + "/": { + "get": { + "description": "Get a list of all available API endpoints with descriptions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "API endpoint index", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, + "/health": { + "get": { + "description": "Check API health status and database connectivity", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "API is healthy", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + }, + "503": { + "description": "API is degraded (database issues)", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, + "/national": { + "get": { + "description": "Retrieve national COVID-19 cases data with optional date range filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get national COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/national/latest": { + "get": { + "description": "Retrieve the most recent national COVID-19 case data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get latest national COVID-19 case", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces": { + "get": { + "description": "Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "provinces" + ], + "summary": "Get provinces with COVID-19 data", + "parameters": [ + { + "type": "boolean", + "description": "Exclude latest case data (default: false)", + "name": "exclude_latest_case", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Basic province list when exclude_latest_case=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Province" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/{provinceId}/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Province ID (e.g., '31' for Jakarta)", + "name": "provinceId", + "in": "path" + }, + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + } + }, + "definitions": { + "handler.Response": { + "type": "object", + "properties": { + "data": {}, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "models.CasePercentages": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "deceased": { + "type": "number" + }, + "recovered": { + "type": "number" + } + } + }, + "models.CumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.DailySupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.NationalCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.CumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.DailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "statistics": { + "$ref": "#/definitions/models.NationalCaseStatistics" + } + } + }, + "models.NationalCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "models.PaginatedResponse": { + "type": "object", + "properties": { + "data": {}, + "pagination": { + "$ref": "#/definitions/models.PaginationMeta" + } + } + }, + "models.PaginationMeta": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "has_prev": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "models.Province": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "models.ProvinceCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.ProvinceCumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.ProvinceDailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "province": { + "$ref": "#/definitions/models.Province" + }, + "statistics": { + "$ref": "#/definitions/models.ProvinceCaseStatistics" + } + } + }, + "models.ProvinceCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ProvinceCumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.ObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.SupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceDailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.DailyObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.DailySupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceWithLatestCase": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "latest_case": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + }, + "name": { + "type": "string" + } + } + }, + "models.ReproductionRate": { + "type": "object", + "properties": { + "lower_bound": { + "type": "number" + }, + "upper_bound": { + "type": "number" + }, + "value": { + "type": "number" + } + } + }, + "models.SupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + }, + "tags": [ + { + "description": "Health check operations", + "name": "health" + }, + { + "description": "National COVID-19 case operations (for context)", + "name": "national" + }, + { + "description": "Province information and COVID-19 case operations (focus on Sulawesi Tengah)", + "name": "provinces" + }, + { + "description": "Province-level COVID-19 case data with pagination support", + "name": "province-cases" + } + ] +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..db5f118 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,485 @@ +basePath: /api/v1 +definitions: + handler.Response: + properties: + data: {} + error: + type: string + message: + type: string + status: + type: string + type: object + models.CasePercentages: + properties: + active: + type: number + deceased: + type: number + recovered: + type: number + type: object + models.CumulativeCases: + properties: + active: + type: integer + deceased: + type: integer + positive: + type: integer + recovered: + type: integer + type: object + models.DailyCases: + properties: + active: + type: integer + deceased: + type: integer + positive: + type: integer + recovered: + type: integer + type: object + models.DailyObservationData: + properties: + active: + type: integer + finished: + type: integer + type: object + models.DailySupervisionData: + properties: + active: + type: integer + finished: + type: integer + type: object + models.NationalCaseResponse: + properties: + cumulative: + $ref: '#/definitions/models.CumulativeCases' + daily: + $ref: '#/definitions/models.DailyCases' + date: + type: string + day: + type: integer + statistics: + $ref: '#/definitions/models.NationalCaseStatistics' + type: object + models.NationalCaseStatistics: + properties: + percentages: + $ref: '#/definitions/models.CasePercentages' + reproduction_rate: + $ref: '#/definitions/models.ReproductionRate' + type: object + models.ObservationData: + properties: + active: + type: integer + finished: + type: integer + total: + type: integer + type: object + models.PaginatedResponse: + properties: + data: {} + pagination: + $ref: '#/definitions/models.PaginationMeta' + type: object + models.PaginationMeta: + properties: + has_next: + type: boolean + has_prev: + type: boolean + limit: + type: integer + offset: + type: integer + page: + type: integer + total: + type: integer + total_pages: + type: integer + type: object + models.Province: + properties: + id: + type: string + name: + type: string + type: object + models.ProvinceCaseResponse: + properties: + cumulative: + $ref: '#/definitions/models.ProvinceCumulativeCases' + daily: + $ref: '#/definitions/models.ProvinceDailyCases' + date: + type: string + day: + type: integer + province: + $ref: '#/definitions/models.Province' + statistics: + $ref: '#/definitions/models.ProvinceCaseStatistics' + type: object + models.ProvinceCaseStatistics: + properties: + percentages: + $ref: '#/definitions/models.CasePercentages' + reproduction_rate: + $ref: '#/definitions/models.ReproductionRate' + type: object + models.ProvinceCumulativeCases: + properties: + active: + type: integer + deceased: + type: integer + odp: + $ref: '#/definitions/models.ObservationData' + pdp: + $ref: '#/definitions/models.SupervisionData' + positive: + type: integer + recovered: + type: integer + type: object + models.ProvinceDailyCases: + properties: + active: + type: integer + deceased: + type: integer + odp: + $ref: '#/definitions/models.DailyObservationData' + pdp: + $ref: '#/definitions/models.DailySupervisionData' + positive: + type: integer + recovered: + type: integer + type: object + models.ProvinceWithLatestCase: + properties: + id: + type: string + latest_case: + $ref: '#/definitions/models.ProvinceCaseResponse' + name: + type: string + type: object + models.ReproductionRate: + properties: + lower_bound: + type: number + upper_bound: + type: number + value: + type: number + type: object + models.SupervisionData: + properties: + active: + type: integer + finished: + type: integer + total: + type: integer + type: object +host: pico-api.banuacoder.com +info: + contact: + email: support@banuacoder.com + name: API Support + url: https://github.com/banua-coder/pico-api-go + description: A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central + Sulawesi), with additional national and provincial data for context. Features + enhanced ODP/PDP grouping and hybrid pagination. + license: + name: MIT + url: https://opensource.org/licenses/MIT + termsOfService: http://swagger.io/terms/ + title: Sulawesi Tengah COVID-19 Data API + version: 2.0.2 +paths: + /: + get: + consumes: + - application/json + description: Get a list of all available API endpoints with descriptions + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + additionalProperties: true + type: object + type: object + summary: API endpoint index + tags: + - health + /health: + get: + consumes: + - application/json + description: Check API health status and database connectivity + produces: + - application/json + responses: + "200": + description: API is healthy + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + additionalProperties: true + type: object + type: object + "503": + description: API is degraded (database issues) + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + additionalProperties: true + type: object + type: object + summary: Health check + tags: + - health + /national: + get: + consumes: + - application/json + description: Retrieve national COVID-19 cases data with optional date range + filtering + parameters: + - description: Start date (YYYY-MM-DD) + in: query + name: start_date + type: string + - description: End date (YYYY-MM-DD) + in: query + name: end_date + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.NationalCaseResponse' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get national COVID-19 cases + tags: + - national + /national/latest: + get: + consumes: + - application/json + description: Retrieve the most recent national COVID-19 case data + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + $ref: '#/definitions/models.NationalCaseResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get latest national COVID-19 case + tags: + - national + /provinces: + get: + consumes: + - application/json + description: Retrieve all provinces with their latest COVID-19 case data by + default. Use exclude_latest_case=true for basic province list only. + parameters: + - description: 'Exclude latest case data (default: false)' + in: query + name: exclude_latest_case + type: boolean + produces: + - application/json + responses: + "200": + description: Basic province list when exclude_latest_case=true + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.Province' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get provinces with COVID-19 data + tags: + - provinces + /provinces/{provinceId}/cases: + get: + consumes: + - application/json + description: Retrieve COVID-19 cases for all provinces or a specific province + with hybrid pagination support + parameters: + - description: Province ID (e.g., '31' for Jakarta) + in: path + name: provinceId + type: string + - description: 'Records per page (default: 50, max: 1000)' + in: query + name: limit + type: integer + - description: 'Records to skip (default: 0)' + in: query + name: offset + type: integer + - description: Return all data without pagination + in: query + name: all + type: boolean + - description: Start date (YYYY-MM-DD) + in: query + name: start_date + type: string + - description: End date (YYYY-MM-DD) + in: query + name: end_date + type: string + produces: + - application/json + responses: + "200": + description: All data response when all=true + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.ProvinceCaseResponse' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get province COVID-19 cases + tags: + - province-cases + /provinces/cases: + get: + consumes: + - application/json + description: Retrieve COVID-19 cases for all provinces or a specific province + with hybrid pagination support + parameters: + - description: 'Records per page (default: 50, max: 1000)' + in: query + name: limit + type: integer + - description: 'Records to skip (default: 0)' + in: query + name: offset + type: integer + - description: Return all data without pagination + in: query + name: all + type: boolean + - description: Start date (YYYY-MM-DD) + in: query + name: start_date + type: string + - description: End date (YYYY-MM-DD) + in: query + name: end_date + type: string + produces: + - application/json + responses: + "200": + description: All data response when all=true + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.ProvinceCaseResponse' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get province COVID-19 cases + tags: + - province-cases +schemes: +- https +- http +swagger: "2.0" +tags: +- description: Health check operations + name: health +- description: National COVID-19 case operations (for context) + name: national +- description: Province information and COVID-19 case operations (focus on Sulawesi + Tengah) + name: provinces +- description: Province-level COVID-19 case data with pagination support + name: province-cases diff --git a/go.mod b/go.mod index 8755a21..4022130 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/banua-coder/pico-api-go -go 1.23 +go 1.24.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -8,12 +8,38 @@ require ( github.com/gorilla/mux v1.8.1 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.11.1 + github.com/swaggo/http-swagger v1.3.4 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.22.0 // indirect + github.com/go-openapi/jsonreference v0.21.1 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.24.1 // indirect + github.com/go-openapi/swag/cmdutils v0.24.0 // indirect + github.com/go-openapi/swag/conv v0.24.0 // indirect + github.com/go-openapi/swag/fileutils v0.24.0 // indirect + github.com/go-openapi/swag/jsonname v0.24.0 // indirect + github.com/go-openapi/swag/jsonutils v0.24.0 // indirect + github.com/go-openapi/swag/loading v0.24.0 // indirect + github.com/go-openapi/swag/mangling v0.24.0 // indirect + github.com/go-openapi/swag/netutils v0.24.0 // indirect + github.com/go-openapi/swag/stringutils v0.24.0 // indirect + github.com/go-openapi/swag/typeutils v0.24.0 // indirect + github.com/go-openapi/swag/yamlutils v0.24.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/swag v1.16.6 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/tools v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2724e41..83d2836 100644 --- a/go.sum +++ b/go.sum @@ -2,22 +2,108 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= +github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= +github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= +github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= +github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= +github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= +github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= +github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= +github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= +github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= +github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= +github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= +github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= +github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= +github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= +github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= +github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= +github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= +github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= +github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= +github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= +github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= +github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= +github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= +github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= +github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= +github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index db2e6be..a994a9c 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -7,6 +7,7 @@ import ( "github.com/banua-coder/pico-api-go/internal/models" "github.com/banua-coder/pico-api-go/internal/service" "github.com/banua-coder/pico-api-go/pkg/database" + "github.com/banua-coder/pico-api-go/pkg/utils" "github.com/gorilla/mux" ) @@ -22,6 +23,19 @@ func NewCovidHandler(covidService service.CovidService, db *database.DB) *CovidH } } +// GetNationalCases godoc +// +// @Summary Get national COVID-19 cases +// @Description Retrieve national COVID-19 cases data with optional date range filtering +// @Tags national +// @Accept json +// @Produce json +// @Param start_date query string false "Start date (YYYY-MM-DD)" +// @Param end_date query string false "End date (YYYY-MM-DD)" +// @Success 200 {object} Response{data=[]models.NationalCaseResponse} +// @Failure 400 {object} Response +// @Failure 500 {object} Response +// @Router /national [get] func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) { startDate := r.URL.Query().Get("start_date") endDate := r.URL.Query().Get("end_date") @@ -49,6 +63,17 @@ func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) writeSuccessResponse(w, responseData) } +// GetLatestNationalCase godoc +// +// @Summary Get latest national COVID-19 case +// @Description Retrieve the most recent national COVID-19 case data +// @Tags national +// @Accept json +// @Produce json +// @Success 200 {object} Response{data=models.NationalCaseResponse} +// @Failure 404 {object} Response +// @Failure 500 {object} Response +// @Router /national/latest [get] func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Request) { nationalCase, err := h.covidService.GetLatestNationalCase() if err != nil { @@ -66,73 +91,196 @@ func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Requ writeSuccessResponse(w, responseData) } +// GetProvinces godoc +// +// @Summary Get provinces with COVID-19 data +// @Description Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only. +// @Tags provinces +// @Accept json +// @Produce json +// @Param exclude_latest_case query boolean false "Exclude latest case data (default: false)" +// @Success 200 {object} Response{data=[]models.ProvinceWithLatestCase} "Provinces with latest case data" +// @Success 200 {object} Response{data=[]models.Province} "Basic province list when exclude_latest_case=true" +// @Failure 500 {object} Response +// @Router /provinces [get] func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { - provinces, err := h.covidService.GetProvinces() + // Check if exclude_latest_case query parameter is set to get basic province list only + excludeLatestCase := r.URL.Query().Get("exclude_latest_case") == "true" + + if excludeLatestCase { + provinces, err := h.covidService.GetProvinces() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + writeSuccessResponse(w, provinces) + return + } + + // Default behavior: include latest case data for COVID-19 context + provincesWithCases, err := h.covidService.GetProvincesWithLatestCase() if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - - writeSuccessResponse(w, provinces) + writeSuccessResponse(w, provincesWithCases) } +// GetProvinceCases godoc +// +// @Summary Get province COVID-19 cases +// @Description Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support +// @Tags province-cases +// @Accept json +// @Produce json +// @Param provinceId path string false "Province ID (e.g., '31' for Jakarta)" +// @Param limit query integer false "Records per page (default: 50, max: 1000)" +// @Param offset query integer false "Records to skip (default: 0)" +// @Param all query boolean false "Return all data without pagination" +// @Param start_date query string false "Start date (YYYY-MM-DD)" +// @Param end_date query string false "End date (YYYY-MM-DD)" +// @Success 200 {object} Response{data=models.PaginatedResponse{data=[]models.ProvinceCaseResponse}} "Paginated response" +// @Success 200 {object} Response{data=[]models.ProvinceCaseResponse} "All data response when all=true" +// @Failure 400 {object} Response +// @Failure 500 {object} Response +// @Router /provinces/cases [get] +// @Router /provinces/{provinceId}/cases [get] func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) provinceID := vars["provinceId"] + + // Parse query parameters + limit := utils.ParseIntQueryParam(r, "limit", 50) + offset := utils.ParseIntQueryParam(r, "offset", 0) + all := utils.ParseBoolQueryParam(r, "all") + startDate := r.URL.Query().Get("start_date") + endDate := r.URL.Query().Get("end_date") + + // Validate pagination params + limit, offset = utils.ValidatePaginationParams(limit, offset) if provinceID == "" { - startDate := r.URL.Query().Get("start_date") - endDate := r.URL.Query().Get("end_date") + // Handle all provinces cases + if all { + // Return all data without pagination + if startDate != "" && endDate != "" { + cases, err := h.covidService.GetAllProvinceCasesByDateRange(startDate, endDate) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) + return + } + + cases, err := h.covidService.GetAllProvinceCases() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) + return + } + + // Return paginated data + if startDate != "" && endDate != "" { + cases, total, err := h.covidService.GetAllProvinceCasesByDateRangePaginated(startDate, endDate, limit, offset) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) + return + } + + cases, total, err := h.covidService.GetAllProvinceCasesPaginated(limit, offset) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) + return + } + // Handle specific province cases + if all { + // Return all data without pagination if startDate != "" && endDate != "" { - cases, err := h.covidService.GetAllProvinceCasesByDateRange(startDate, endDate) + cases, err := h.covidService.GetProvinceCasesByDateRange(provinceID, startDate, endDate) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) writeSuccessResponse(w, responseData) return } - - cases, err := h.covidService.GetAllProvinceCases() + + cases, err := h.covidService.GetProvinceCases(provinceID) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) writeSuccessResponse(w, responseData) return } - - startDate := r.URL.Query().Get("start_date") - endDate := r.URL.Query().Get("end_date") - + + // Return paginated data if startDate != "" && endDate != "" { - cases, err := h.covidService.GetProvinceCasesByDateRange(provinceID, startDate, endDate) + cases, total, err := h.covidService.GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate, limit, offset) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) - writeSuccessResponse(w, responseData) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) return } - - cases, err := h.covidService.GetProvinceCases(provinceID) + + cases, total, err := h.covidService.GetProvinceCasesPaginated(provinceID, limit, offset) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) - writeSuccessResponse(w, responseData) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) } +// HealthCheck godoc +// +// @Summary Health check +// @Description Check API health status and database connectivity +// @Tags health +// @Accept json +// @Produce json +// @Success 200 {object} Response{data=map[string]interface{}} "API is healthy" +// @Success 503 {object} Response{data=map[string]interface{}} "API is degraded (database issues)" +// @Router /health [get] func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { health := map[string]interface{}{ "status": "healthy", @@ -180,3 +328,82 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { Data: health, }) } + +// GetAPIIndex godoc +// +// @Summary API endpoint index +// @Description Get a list of all available API endpoints with descriptions +// @Tags health +// @Accept json +// @Produce json +// @Success 200 {object} Response{data=map[string]interface{}} +// @Router / [get] +func (h *CovidHandler) GetAPIIndex(w http.ResponseWriter, r *http.Request) { + endpoints := map[string]interface{}{ + "api": map[string]interface{}{ + "title": "Sulawesi Tengah COVID-19 Data API", + "version": "2.0.2", + "description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi)", + }, + "documentation": map[string]interface{}{ + "swagger_ui": "/swagger/index.html", + "openapi": map[string]string{ + "yaml": "/docs/swagger.yaml", + "json": "/docs/swagger.json", + }, + }, + "endpoints": map[string]interface{}{ + "health": map[string]interface{}{ + "url": "/api/v1/health", + "method": "GET", + "description": "Check API health status and database connectivity", + }, + "national": map[string]interface{}{ + "list": map[string]string{ + "url": "/api/v1/national", + "method": "GET", + "description": "Get national COVID-19 cases (with optional date range)", + }, + "latest": map[string]string{ + "url": "/api/v1/national/latest", + "method": "GET", + "description": "Get latest national COVID-19 case data", + }, + }, + "provinces": map[string]interface{}{ + "list": map[string]string{ + "url": "/api/v1/provinces", + "method": "GET", + "description": "Get provinces with latest case data (default)", + }, + "cases": map[string]interface{}{ + "all": map[string]string{ + "url": "/api/v1/provinces/cases", + "method": "GET", + "description": "Get province cases (paginated by default, ?all=true for complete data)", + }, + "specific": map[string]string{ + "url": "/api/v1/provinces/{provinceId}/cases", + "method": "GET", + "description": "Get cases for specific province (e.g., /api/v1/provinces/72/cases for Sulawesi Tengah)", + }, + }, + }, + }, + "features": []string{ + "Hybrid pagination system (paginated by default, ?all=true for complete data)", + "Date range filtering (?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD)", + "Enhanced ODP/PDP data grouping", + "Provinces with latest case data by default", + "Sulawesi Tengah focused with national context data", + }, + "examples": map[string]interface{}{ + "sulawesi_tengah_cases": "/api/v1/provinces/72/cases", + "paginated_data": "/api/v1/provinces/cases?limit=100&offset=50", + "date_range": "/api/v1/national?start_date=2024-01-01&end_date=2024-12-31", + "complete_dataset": "/api/v1/provinces/cases?all=true", + }, + } + + writeSuccessResponse(w, endpoints) +} diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index 421f175..bb44299 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -42,6 +42,11 @@ func (m *MockCovidService) GetProvinces() ([]models.Province, error) { return args.Get(0).([]models.Province), args.Error(1) } +func (m *MockCovidService) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) { + args := m.Called() + return args.Get(0).([]models.ProvinceWithLatestCase), args.Error(1) +} + func (m *MockCovidService) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) { args := m.Called(provinceID) return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) @@ -62,6 +67,27 @@ func (m *MockCovidService) GetAllProvinceCasesByDateRange(startDate, endDate str return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) } +// Paginated methods +func (m *MockCovidService) GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + func TestCovidHandler_GetNationalCases(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) @@ -188,12 +214,28 @@ func TestCovidHandler_GetProvinces(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) - expectedProvinces := []models.Province{ - {ID: "11", Name: "Aceh"}, - {ID: "31", Name: "DKI Jakarta"}, + expectedProvinces := []models.ProvinceWithLatestCase{ + { + Province: models.Province{ID: "11", Name: "Aceh"}, + LatestCase: &models.ProvinceCaseResponse{ + Day: 100, + Daily: models.ProvinceDailyCases{ + Positive: 10, + }, + }, + }, + { + Province: models.Province{ID: "31", Name: "DKI Jakarta"}, + LatestCase: &models.ProvinceCaseResponse{ + Day: 101, + Daily: models.ProvinceDailyCases{ + Positive: 25, + }, + }, + }, } - mockService.On("GetProvinces").Return(expectedProvinces, nil) + mockService.On("GetProvincesWithLatestCase").Return(expectedProvinces, nil) req, err := http.NewRequest("GET", "/api/v1/provinces", nil) assert.NoError(t, err) @@ -211,15 +253,16 @@ func TestCovidHandler_GetProvinces(t *testing.T) { mockService.AssertExpectations(t) } -func TestCovidHandler_GetProvinceCases_AllProvinces(t *testing.T) { +func TestCovidHandler_GetProvinceCases_AllProvinces_Paginated(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) expectedCases := []models.ProvinceCaseWithDate{ {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, } + expectedTotal := 100 - mockService.On("GetAllProvinceCases").Return(expectedCases, nil) + mockService.On("GetAllProvinceCasesPaginated", 50, 0).Return(expectedCases, expectedTotal, nil) req, err := http.NewRequest("GET", "/api/v1/provinces/cases", nil) assert.NoError(t, err) @@ -234,18 +277,34 @@ func TestCovidHandler_GetProvinceCases_AllProvinces(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "success", response.Status) + // Verify paginated response structure + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") + + pagination, ok := paginatedData["pagination"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, float64(50), pagination["limit"]) + assert.Equal(t, float64(0), pagination["offset"]) + assert.Equal(t, float64(100), pagination["total"]) + assert.Equal(t, float64(1), pagination["page"]) + assert.Equal(t, true, pagination["has_next"]) + assert.Equal(t, false, pagination["has_prev"]) + mockService.AssertExpectations(t) } -func TestCovidHandler_GetProvinceCases_SpecificProvince(t *testing.T) { +func TestCovidHandler_GetProvinceCases_SpecificProvince_Paginated(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) expectedCases := []models.ProvinceCaseWithDate{ {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, } + expectedTotal := 50 - mockService.On("GetProvinceCases", "11").Return(expectedCases, nil) + mockService.On("GetProvinceCasesPaginated", "11", 50, 0).Return(expectedCases, expectedTotal, nil) req, err := http.NewRequest("GET", "/api/v1/provinces/11/cases", nil) assert.NoError(t, err) @@ -262,9 +321,250 @@ func TestCovidHandler_GetProvinceCases_SpecificProvince(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "success", response.Status) + // Verify paginated response structure + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_AllData(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, + {ProvinceCase: models.ProvinceCase{ID: 2, ProvinceID: "31", Positive: 100}}, + } + + mockService.On("GetAllProvinceCases").Return(expectedCases, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?all=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify non-paginated response structure (direct array) + responseArray, ok := response.Data.([]interface{}) + assert.True(t, ok) + assert.Len(t, responseArray, 2) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_CustomPagination(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 3, ProvinceID: "12", Positive: 25}}, + } + expectedTotal := 200 + + mockService.On("GetAllProvinceCasesPaginated", 100, 50).Return(expectedCases, expectedTotal, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?limit=100&offset=50", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify custom pagination metadata + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + + pagination, ok := paginatedData["pagination"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, float64(100), pagination["limit"]) + assert.Equal(t, float64(50), pagination["offset"]) + assert.Equal(t, float64(200), pagination["total"]) + assert.Equal(t, float64(1), pagination["page"]) + assert.Equal(t, true, pagination["has_next"]) + assert.Equal(t, true, pagination["has_prev"]) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_DateRange_Paginated(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, + } + expectedTotal := 30 + + mockService.On("GetAllProvinceCasesByDateRangePaginated", "2024-01-01", "2024-01-31", 50, 0).Return(expectedCases, expectedTotal, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?start_date=2024-01-01&end_date=2024-01-31", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify paginated response + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "pagination") + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_DateRange_AllData(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, + } + + mockService.On("GetAllProvinceCasesByDateRange", "2024-01-01", "2024-01-31").Return(expectedCases, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?start_date=2024-01-01&end_date=2024-01-31&all=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify non-paginated response structure + responseArray, ok := response.Data.([]interface{}) + assert.True(t, ok) + assert.Len(t, responseArray, 1) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_SpecificProvince_AllData(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "31", Positive: 200}}, + } + + mockService.On("GetProvinceCases", "31").Return(expectedCases, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/31/cases?all=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + router := mux.NewRouter() + router.HandleFunc("/api/v1/provinces/{provinceId}/cases", handler.GetProvinceCases) + router.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify non-paginated response structure + responseArray, ok := response.Data.([]interface{}) + assert.True(t, ok) + assert.Len(t, responseArray, 1) + mockService.AssertExpectations(t) } +func TestCovidHandler_GetProvinces_ExcludeLatestCase(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedProvinces := []models.Province{ + {ID: "11", Name: "Aceh"}, + {ID: "31", Name: "DKI Jakarta"}, + } + + mockService.On("GetProvinces").Return(expectedProvinces, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces?exclude_latest_case=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinces(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetAPIIndex(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + req, err := http.NewRequest("GET", "/api/v1", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetAPIIndex(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify structure contains expected keys + data, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, data, "api") + assert.Contains(t, data, "documentation") + assert.Contains(t, data, "endpoints") + assert.Contains(t, data, "features") + assert.Contains(t, data, "examples") + + // Verify API info + apiInfo, ok := data["api"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "Sulawesi Tengah COVID-19 Data API", apiInfo["title"]) + assert.Equal(t, "2.0.2", apiInfo["version"]) + + // Verify endpoints structure + endpoints, ok := data["endpoints"].(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, endpoints, "health") + assert.Contains(t, endpoints, "national") + assert.Contains(t, endpoints, "provinces") +} + func TestCovidHandler_HealthCheck(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) @@ -286,7 +586,7 @@ func TestCovidHandler_HealthCheck(t *testing.T) { assert.True(t, ok) assert.Equal(t, "degraded", data["status"]) assert.Equal(t, "COVID-19 API", data["service"]) - assert.Equal(t, "2.0.1", data["version"]) + assert.Equal(t, "2.0.2", data["version"]) assert.Contains(t, data, "database") dbData, ok := data["database"].(map[string]interface{}) diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 13dd612..5bb52ab 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -1,9 +1,12 @@ package handler import ( + "net/http" + "github.com/banua-coder/pico-api-go/internal/service" "github.com/banua-coder/pico-api-go/pkg/database" "github.com/gorilla/mux" + httpSwagger "github.com/swaggo/http-swagger" ) func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router { @@ -13,6 +16,11 @@ func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router api := router.PathPrefix("/api/v1").Subrouter() + // API index endpoint + api.HandleFunc("", covidHandler.GetAPIIndex).Methods("GET", "OPTIONS") + api.HandleFunc("/", covidHandler.GetAPIIndex).Methods("GET", "OPTIONS") + + // Main endpoints api.HandleFunc("/health", covidHandler.HealthCheck).Methods("GET", "OPTIONS") api.HandleFunc("/national", covidHandler.GetNationalCases).Methods("GET", "OPTIONS") api.HandleFunc("/national/latest", covidHandler.GetLatestNationalCase).Methods("GET", "OPTIONS") @@ -20,5 +28,13 @@ func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router api.HandleFunc("/provinces/cases", covidHandler.GetProvinceCases).Methods("GET", "OPTIONS") api.HandleFunc("/provinces/{provinceId}/cases", covidHandler.GetProvinceCases).Methods("GET", "OPTIONS") + // Swagger documentation + router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler).Methods("GET") + + // Redirect root to swagger docs for convenience + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/swagger/index.html", http.StatusFound) + }).Methods("GET") + return router } \ No newline at end of file diff --git a/internal/models/pagination.go b/internal/models/pagination.go new file mode 100644 index 0000000..19b3765 --- /dev/null +++ b/internal/models/pagination.go @@ -0,0 +1,34 @@ +package models + +// PaginationMeta contains metadata for paginated responses +type PaginationMeta struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + Page int `json:"page"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// PaginatedResponse wraps data with pagination metadata +type PaginatedResponse struct { + Data interface{} `json:"data"` + Pagination PaginationMeta `json:"pagination"` +} + +// CalculatePaginationMeta calculates pagination metadata +func CalculatePaginationMeta(limit, offset, total int) PaginationMeta { + totalPages := (total + limit - 1) / limit // Ceiling division + page := (offset / limit) + 1 + + return PaginationMeta{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + Page: page, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } +} \ No newline at end of file diff --git a/internal/models/pagination_test.go b/internal/models/pagination_test.go new file mode 100644 index 0000000..4adce6e --- /dev/null +++ b/internal/models/pagination_test.go @@ -0,0 +1,205 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCalculatePaginationMeta(t *testing.T) { + tests := []struct { + name string + limit int + offset int + total int + expectedMeta PaginationMeta + }{ + { + name: "First page with results", + limit: 50, + offset: 0, + total: 200, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 0, + Total: 200, + TotalPages: 4, + Page: 1, + HasNext: true, + HasPrev: false, + }, + }, + { + name: "Middle page", + limit: 50, + offset: 50, + total: 200, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 50, + Total: 200, + TotalPages: 4, + Page: 2, + HasNext: true, + HasPrev: true, + }, + }, + { + name: "Last page", + limit: 50, + offset: 150, + total: 200, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 150, + Total: 200, + TotalPages: 4, + Page: 4, + HasNext: false, + HasPrev: true, + }, + }, + { + name: "Single page with all data", + limit: 100, + offset: 0, + total: 50, + expectedMeta: PaginationMeta{ + Limit: 100, + Offset: 0, + Total: 50, + TotalPages: 1, + Page: 1, + HasNext: false, + HasPrev: false, + }, + }, + { + name: "Exact fit last page", + limit: 25, + offset: 75, + total: 100, + expectedMeta: PaginationMeta{ + Limit: 25, + Offset: 75, + Total: 100, + TotalPages: 4, + Page: 4, + HasNext: false, + HasPrev: true, + }, + }, + { + name: "Empty result set", + limit: 50, + offset: 0, + total: 0, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 0, + Total: 0, + TotalPages: 0, + Page: 1, + HasNext: false, + HasPrev: false, + }, + }, + { + name: "Large offset beyond total", + limit: 50, + offset: 500, + total: 100, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 500, + Total: 100, + TotalPages: 2, + Page: 11, + HasNext: false, + HasPrev: true, + }, + }, + { + name: "Partial last page", + limit: 30, + offset: 90, + total: 100, + expectedMeta: PaginationMeta{ + Limit: 30, + Offset: 90, + Total: 100, + TotalPages: 4, + Page: 4, + HasNext: false, + HasPrev: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + meta := CalculatePaginationMeta(tt.limit, tt.offset, tt.total) + assert.Equal(t, tt.expectedMeta, meta) + }) + } +} + +func TestPaginationMetaCalculations(t *testing.T) { + t.Run("Total pages calculation for different scenarios", func(t *testing.T) { + // Test ceiling division for total pages + assert.Equal(t, 4, CalculatePaginationMeta(33, 0, 100).TotalPages) // 100/33 = 3.03 -> 4 pages + assert.Equal(t, 2, CalculatePaginationMeta(50, 0, 100).TotalPages) // 100/50 = 2 -> 2 pages + assert.Equal(t, 3, CalculatePaginationMeta(33, 0, 99).TotalPages) // 99/33 = 3 -> 3 pages + }) + + t.Run("Page number calculation", func(t *testing.T) { + assert.Equal(t, 1, CalculatePaginationMeta(50, 0, 200).Page) // offset 0 = page 1 + assert.Equal(t, 2, CalculatePaginationMeta(50, 50, 200).Page) // offset 50 = page 2 + assert.Equal(t, 3, CalculatePaginationMeta(50, 100, 200).Page) // offset 100 = page 3 + }) + + t.Run("Has next and previous flags", func(t *testing.T) { + // First page + meta := CalculatePaginationMeta(50, 0, 200) + assert.False(t, meta.HasPrev) + assert.True(t, meta.HasNext) + + // Middle page + meta = CalculatePaginationMeta(50, 50, 200) + assert.True(t, meta.HasPrev) + assert.True(t, meta.HasNext) + + // Last page + meta = CalculatePaginationMeta(50, 150, 200) + assert.True(t, meta.HasPrev) + assert.False(t, meta.HasNext) + + // Single page + meta = CalculatePaginationMeta(100, 0, 50) + assert.False(t, meta.HasPrev) + assert.False(t, meta.HasNext) + }) +} + +func TestPaginatedResponse(t *testing.T) { + t.Run("PaginatedResponse structure", func(t *testing.T) { + testData := []string{"item1", "item2", "item3"} + pagination := PaginationMeta{ + Limit: 10, + Offset: 0, + Total: 3, + TotalPages: 1, + Page: 1, + HasNext: false, + HasPrev: false, + } + + response := PaginatedResponse{ + Data: testData, + Pagination: pagination, + } + + assert.Equal(t, testData, response.Data) + assert.Equal(t, pagination, response.Pagination) + }) +} \ No newline at end of file diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go index e802282..f24fe5f 100644 --- a/internal/models/province_case_response.go +++ b/internal/models/province_case_response.go @@ -14,28 +14,48 @@ type ProvinceCaseResponse struct { // ProvinceDailyCases represents new cases for a single day in a province type ProvinceDailyCases struct { - Positive int64 `json:"positive"` - Recovered int64 `json:"recovered"` - Deceased int64 `json:"deceased"` - Active int64 `json:"active"` - PersonUnderObservation int64 `json:"person_under_observation"` - FinishedPersonUnderObservation int64 `json:"finished_person_under_observation"` - PersonUnderSupervision int64 `json:"person_under_supervision"` - FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision"` + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + ODP DailyObservationData `json:"odp"` + PDP DailySupervisionData `json:"pdp"` } // ProvinceCumulativeCases represents total cases accumulated over time in a province type ProvinceCumulativeCases struct { - Positive int64 `json:"positive"` - Recovered int64 `json:"recovered"` - Deceased int64 `json:"deceased"` - Active int64 `json:"active"` - PersonUnderObservation int64 `json:"person_under_observation"` - ActivePersonUnderObservation int64 `json:"active_person_under_observation"` - FinishedPersonUnderObservation int64 `json:"finished_person_under_observation"` - PersonUnderSupervision int64 `json:"person_under_supervision"` - ActivePersonUnderSupervision int64 `json:"active_person_under_supervision"` - FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision"` + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + ODP ObservationData `json:"odp"` + PDP SupervisionData `json:"pdp"` +} + +// DailyObservationData represents daily Person Under Observation (ODP) data +type DailyObservationData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` +} + +// DailySupervisionData represents daily Patient Under Supervision (PDP) data +type DailySupervisionData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` +} + +// ObservationData represents cumulative Person Under Observation (ODP) data +type ObservationData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` + Total int64 `json:"total"` +} + +// SupervisionData represents cumulative Patient Under Supervision (PDP) data +type SupervisionData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` + Total int64 `json:"total"` } // ProvinceCaseStatistics contains calculated statistics and metrics for province data @@ -59,26 +79,34 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Day: pc.Day, Date: date, Daily: ProvinceDailyCases{ - Positive: pc.Positive, - Recovered: pc.Recovered, - Deceased: pc.Deceased, - Active: dailyActive, - PersonUnderObservation: pc.PersonUnderObservation, - FinishedPersonUnderObservation: pc.FinishedPersonUnderObservation, - PersonUnderSupervision: pc.PersonUnderSupervision, - FinishedPersonUnderSupervision: pc.FinishedPersonUnderSupervision, + Positive: pc.Positive, + Recovered: pc.Recovered, + Deceased: pc.Deceased, + Active: dailyActive, + ODP: DailyObservationData{ + Active: pc.PersonUnderObservation - pc.FinishedPersonUnderObservation, + Finished: pc.FinishedPersonUnderObservation, + }, + PDP: DailySupervisionData{ + Active: pc.PersonUnderSupervision - pc.FinishedPersonUnderSupervision, + Finished: pc.FinishedPersonUnderSupervision, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: pc.CumulativePositive, - Recovered: pc.CumulativeRecovered, - Deceased: pc.CumulativeDeceased, - Active: cumulativeActive, - PersonUnderObservation: pc.CumulativePersonUnderObservation, - ActivePersonUnderObservation: activePersonUnderObservation, - FinishedPersonUnderObservation: pc.CumulativeFinishedPersonUnderObservation, - PersonUnderSupervision: pc.CumulativePersonUnderSupervision, - ActivePersonUnderSupervision: activePersonUnderSupervision, - FinishedPersonUnderSupervision: pc.CumulativeFinishedPersonUnderSupervision, + Positive: pc.CumulativePositive, + Recovered: pc.CumulativeRecovered, + Deceased: pc.CumulativeDeceased, + Active: cumulativeActive, + ODP: ObservationData{ + Active: activePersonUnderObservation, + Finished: pc.CumulativeFinishedPersonUnderObservation, + Total: pc.CumulativePersonUnderObservation, + }, + PDP: SupervisionData{ + Active: activePersonUnderSupervision, + Finished: pc.CumulativeFinishedPersonUnderSupervision, + Total: pc.CumulativePersonUnderSupervision, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: calculatePercentages(pc.CumulativePositive, pc.CumulativeRecovered, pc.CumulativeDeceased, cumulativeActive), diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go index 3f362b1..84b71ad 100644 --- a/internal/models/province_case_response_test.go +++ b/internal/models/province_case_response_test.go @@ -52,26 +52,34 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Day: 100, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 150, - Recovered: 120, - Deceased: 10, - Active: 20, // 150 - 120 - 10 - PersonUnderObservation: 25, - FinishedPersonUnderObservation: 20, - PersonUnderSupervision: 30, - FinishedPersonUnderSupervision: 25, + Positive: 150, + Recovered: 120, + Deceased: 10, + Active: 20, // 150 - 120 - 10 + ODP: DailyObservationData{ + Active: 5, // 25 - 20 + Finished: 20, + }, + PDP: DailySupervisionData{ + Active: 5, // 30 - 25 + Finished: 25, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 5000, - Recovered: 4500, - Deceased: 300, - Active: 200, // 5000 - 4500 - 300 - PersonUnderObservation: 800, - ActivePersonUnderObservation: 50, // 800 - 750 - FinishedPersonUnderObservation: 750, - PersonUnderSupervision: 600, - ActivePersonUnderSupervision: 20, // 600 - 580 - FinishedPersonUnderSupervision: 580, + Positive: 5000, + Recovered: 4500, + Deceased: 300, + Active: 200, // 5000 - 4500 - 300 + ODP: ObservationData{ + Active: 50, // 800 - 750 + Finished: 750, + Total: 800, + }, + PDP: SupervisionData{ + Active: 20, // 600 - 580 + Finished: 580, + Total: 600, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ @@ -124,26 +132,34 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Day: 50, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 100, - Recovered: 80, - Deceased: 5, - Active: 15, // 100 - 80 - 5 - PersonUnderObservation: 15, - FinishedPersonUnderObservation: 10, - PersonUnderSupervision: 20, - FinishedPersonUnderSupervision: 15, + Positive: 100, + Recovered: 80, + Deceased: 5, + Active: 15, // 100 - 80 - 5 + ODP: DailyObservationData{ + Active: 5, // 15 - 10 + Finished: 10, + }, + PDP: DailySupervisionData{ + Active: 5, // 20 - 15 + Finished: 15, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 2000, - Recovered: 1800, - Deceased: 100, - Active: 100, // 2000 - 1800 - 100 - PersonUnderObservation: 400, - ActivePersonUnderObservation: 50, // 400 - 350 - FinishedPersonUnderObservation: 350, - PersonUnderSupervision: 300, - ActivePersonUnderSupervision: 10, // 300 - 290 - FinishedPersonUnderSupervision: 290, + Positive: 2000, + Recovered: 1800, + Deceased: 100, + Active: 100, // 2000 - 1800 - 100 + ODP: ObservationData{ + Active: 50, // 400 - 350 + Finished: 350, + Total: 400, + }, + PDP: SupervisionData{ + Active: 10, // 300 - 290 + Finished: 290, + Total: 300, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ @@ -196,26 +212,34 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Day: 1, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 0, - Recovered: 0, - Deceased: 0, - Active: 0, - PersonUnderObservation: 0, - FinishedPersonUnderObservation: 0, - PersonUnderSupervision: 0, - FinishedPersonUnderSupervision: 0, + Positive: 0, + Recovered: 0, + Deceased: 0, + Active: 0, + ODP: DailyObservationData{ + Active: 0, + Finished: 0, + }, + PDP: DailySupervisionData{ + Active: 0, + Finished: 0, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 0, - Recovered: 0, - Deceased: 0, - Active: 0, - PersonUnderObservation: 0, - ActivePersonUnderObservation: 0, - FinishedPersonUnderObservation: 0, - PersonUnderSupervision: 0, - ActivePersonUnderSupervision: 0, - FinishedPersonUnderSupervision: 0, + Positive: 0, + Recovered: 0, + Deceased: 0, + Active: 0, + ODP: ObservationData{ + Active: 0, + Finished: 0, + Total: 0, + }, + PDP: SupervisionData{ + Active: 0, + Finished: 0, + Total: 0, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ @@ -287,26 +311,34 @@ func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { Day: 200, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 50, - Recovered: 40, - Deceased: 2, - Active: 8, // 50 - 40 - 2 - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 12, - FinishedPersonUnderSupervision: 10, + Positive: 50, + Recovered: 40, + Deceased: 2, + Active: 8, // 50 - 40 - 2 + ODP: DailyObservationData{ + Active: 2, // 10 - 8 + Finished: 8, + }, + PDP: DailySupervisionData{ + Active: 2, // 12 - 10 + Finished: 10, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 3000, - Recovered: 2700, - Deceased: 200, - Active: 100, // 3000 - 2700 - 200 - PersonUnderObservation: 500, - ActivePersonUnderObservation: 50, // 500 - 450 - FinishedPersonUnderObservation: 450, - PersonUnderSupervision: 350, - ActivePersonUnderSupervision: 30, // 350 - 320 - FinishedPersonUnderSupervision: 320, + Positive: 3000, + Recovered: 2700, + Deceased: 200, + Active: 100, // 3000 - 2700 - 200 + ODP: ObservationData{ + Active: 50, // 500 - 450 + Finished: 450, + Total: 500, + }, + PDP: SupervisionData{ + Active: 30, // 350 - 320 + Finished: 320, + Total: 350, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ diff --git a/internal/models/province_with_case.go b/internal/models/province_with_case.go new file mode 100644 index 0000000..0375e91 --- /dev/null +++ b/internal/models/province_with_case.go @@ -0,0 +1,7 @@ +package models + +// ProvinceWithLatestCase represents a province with its latest COVID-19 case data +type ProvinceWithLatestCase struct { + Province + LatestCase *ProvinceCaseResponse `json:"latest_case,omitempty"` +} \ No newline at end of file diff --git a/internal/repository/province_case_repository.go b/internal/repository/province_case_repository.go index 06a8687..74c24b6 100644 --- a/internal/repository/province_case_repository.go +++ b/internal/repository/province_case_repository.go @@ -11,9 +11,13 @@ import ( type ProvinceCaseRepository interface { GetAll() ([]models.ProvinceCaseWithDate, error) + GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetByProvinceID(provinceID string) ([]models.ProvinceCaseWithDate, error) + GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetByProvinceIDAndDateRange(provinceID string, startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) + GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetByDateRange(startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) + GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetLatestByProvinceID(provinceID string) (*models.ProvinceCaseWithDate, error) } @@ -41,6 +45,39 @@ func (r *provinceCaseRepository) GetAll() ([]models.ProvinceCaseWithDate, error) return r.queryProvinceCases(query) } +func (r *provinceCaseRepository) GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id` + + var total int + err := r.db.QueryRow(countQuery).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + ORDER BY nc.date DESC, p.name + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetByProvinceID(provinceID string) ([]models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, @@ -58,6 +95,41 @@ func (r *provinceCaseRepository) GetByProvinceID(provinceID string) ([]models.Pr return r.queryProvinceCases(query, provinceID) } +func (r *provinceCaseRepository) GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE pc.province_id = ?` + + var total int + err := r.db.QueryRow(countQuery, provinceID).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases for province %s: %w", provinceID, err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? + ORDER BY nc.date DESC + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetByProvinceIDAndDateRange(provinceID string, startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, @@ -75,6 +147,41 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRange(provinceID string, return r.queryProvinceCases(query, provinceID, startDate, endDate) } +func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ?` + + var total int + err := r.db.QueryRow(countQuery, provinceID, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases for province %s in date range: %w", provinceID, err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ? + ORDER BY nc.date DESC + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetByDateRange(startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, @@ -92,6 +199,41 @@ func (r *provinceCaseRepository) GetByDateRange(startDate, endDate time.Time) ([ return r.queryProvinceCases(query, startDate, endDate) } +func (r *provinceCaseRepository) GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE nc.date BETWEEN ? AND ?` + + var total int + err := r.db.QueryRow(countQuery, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases in date range: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE nc.date BETWEEN ? AND ? + ORDER BY nc.date DESC, p.name + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetLatestByProvinceID(provinceID string) (*models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, diff --git a/internal/service/covid_service.go b/internal/service/covid_service.go index 4650e8f..a76b1c4 100644 --- a/internal/service/covid_service.go +++ b/internal/service/covid_service.go @@ -13,10 +13,15 @@ type CovidService interface { GetNationalCasesByDateRange(startDate, endDate string) ([]models.NationalCase, error) GetLatestNationalCase() (*models.NationalCase, error) GetProvinces() ([]models.Province, error) + GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) + GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetProvinceCasesByDateRange(provinceID, startDate, endDate string) ([]models.ProvinceCaseWithDate, error) + GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetAllProvinceCases() ([]models.ProvinceCaseWithDate, error) + GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetAllProvinceCasesByDateRange(startDate, endDate string) ([]models.ProvinceCaseWithDate, error) + GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) } type covidService struct { @@ -79,6 +84,36 @@ func (s *covidService) GetProvinces() ([]models.Province, error) { return provinces, nil } +func (s *covidService) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) { + provinces, err := s.provinceRepo.GetAll() + if err != nil { + return nil, fmt.Errorf("failed to get provinces: %w", err) + } + + result := make([]models.ProvinceWithLatestCase, len(provinces)) + + for i, province := range provinces { + result[i] = models.ProvinceWithLatestCase{ + Province: province, + } + + // Get latest case for this province + latestCase, err := s.provinceCaseRepo.GetLatestByProvinceID(province.ID) + if err != nil { + // If error or no data, continue without latest case + continue + } + + if latestCase != nil { + // Transform to response format + caseResponse := latestCase.TransformToResponse() + result[i].LatestCase = &caseResponse + } + } + + return result, nil +} + func (s *covidService) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) { cases, err := s.provinceCaseRepo.GetByProvinceID(provinceID) if err != nil { @@ -129,4 +164,56 @@ func (s *covidService) GetAllProvinceCasesByDateRange(startDate, endDate string) return nil, fmt.Errorf("failed to get all province cases by date range: %w", err) } return cases, nil +} + +func (s *covidService) GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + cases, total, err := s.provinceCaseRepo.GetByProvinceIDPaginated(provinceID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get province cases paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid start date format: %w", err) + } + + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid end date format: %w", err) + } + + cases, total, err := s.provinceCaseRepo.GetByProvinceIDAndDateRangePaginated(provinceID, start, end, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get province cases by date range paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + cases, total, err := s.provinceCaseRepo.GetAllPaginated(limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get all province cases paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid start date format: %w", err) + } + + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid end date format: %w", err) + } + + cases, total, err := s.provinceCaseRepo.GetByDateRangePaginated(start, end, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get all province cases by date range paginated: %w", err) + } + return cases, total, nil } \ No newline at end of file diff --git a/internal/service/covid_service_test.go b/internal/service/covid_service_test.go index 14adb38..d3030e8 100644 --- a/internal/service/covid_service_test.go +++ b/internal/service/covid_service_test.go @@ -89,6 +89,27 @@ func (m *MockProvinceCaseRepository) GetLatestByProvinceID(provinceID string) (* return result.(*models.ProvinceCaseWithDate), args.Error(1) } +// Paginated methods +func (m *MockProvinceCaseRepository) GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepository) GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepository) GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + func setupMockService() (*MockNationalCaseRepository, *MockProvinceRepository, *MockProvinceCaseRepository, CovidService) { mockNationalRepo := new(MockNationalCaseRepository) mockProvinceRepo := new(MockProvinceRepository) diff --git a/pkg/utils/query.go b/pkg/utils/query.go new file mode 100644 index 0000000..06a2a01 --- /dev/null +++ b/pkg/utils/query.go @@ -0,0 +1,63 @@ +package utils + +import ( + "net/http" + "strconv" + "strings" +) + +// ParseIntQueryParam parses an integer query parameter with a default value +func ParseIntQueryParam(r *http.Request, key string, defaultValue int) int { + valueStr := r.URL.Query().Get(key) + if valueStr == "" { + return defaultValue + } + + value, err := strconv.Atoi(valueStr) + if err != nil { + return defaultValue + } + + return value +} + +// ParseBoolQueryParam parses a boolean query parameter +func ParseBoolQueryParam(r *http.Request, key string) bool { + return r.URL.Query().Get(key) == "true" +} + +// ParseStringArrayQueryParam parses a comma-separated string parameter into array +func ParseStringArrayQueryParam(r *http.Request, key string) []string { + valueStr := r.URL.Query().Get(key) + if valueStr == "" { + return nil + } + + values := strings.Split(valueStr, ",") + var result []string + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + result = append(result, v) + } + } + + return result +} + +// ValidatePaginationParams validates and adjusts pagination parameters +func ValidatePaginationParams(limit, offset int) (int, int) { + // Validate limit + if limit <= 0 { + limit = 50 // Default limit + } else if limit > 1000 { + limit = 1000 // Max limit + } + + // Validate offset + if offset < 0 { + offset = 0 + } + + return limit, offset +} \ No newline at end of file diff --git a/pkg/utils/query_test.go b/pkg/utils/query_test.go new file mode 100644 index 0000000..d05042d --- /dev/null +++ b/pkg/utils/query_test.go @@ -0,0 +1,222 @@ +package utils + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseIntQueryParam(t *testing.T) { + tests := []struct { + name string + queryValue string + defaultValue int + expected int + }{ + { + name: "Valid integer", + queryValue: "100", + defaultValue: 50, + expected: 100, + }, + { + name: "Empty value uses default", + queryValue: "", + defaultValue: 50, + expected: 50, + }, + { + name: "Invalid integer uses default", + queryValue: "not-a-number", + defaultValue: 50, + expected: 50, + }, + { + name: "Zero value", + queryValue: "0", + defaultValue: 50, + expected: 0, + }, + { + name: "Negative value", + queryValue: "-10", + defaultValue: 50, + expected: -10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{"test_param": []string{tt.queryValue}}.Encode(), + }, + } + + result := ParseIntQueryParam(req, "test_param", tt.defaultValue) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseBoolQueryParam(t *testing.T) { + tests := []struct { + name string + queryValue string + expected bool + }{ + { + name: "true value", + queryValue: "true", + expected: true, + }, + { + name: "false value", + queryValue: "false", + expected: false, + }, + { + name: "empty value", + queryValue: "", + expected: false, + }, + { + name: "non-boolean value", + queryValue: "yes", + expected: false, + }, + { + name: "1 is not true", + queryValue: "1", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{"test_param": []string{tt.queryValue}}.Encode(), + }, + } + + result := ParseBoolQueryParam(req, "test_param") + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseStringArrayQueryParam(t *testing.T) { + tests := []struct { + name string + queryValue string + expected []string + }{ + { + name: "Single value", + queryValue: "value1", + expected: []string{"value1"}, + }, + { + name: "Multiple values", + queryValue: "value1,value2,value3", + expected: []string{"value1", "value2", "value3"}, + }, + { + name: "Values with spaces", + queryValue: "value1, value2 , value3", + expected: []string{"value1", "value2", "value3"}, + }, + { + name: "Empty string returns nil", + queryValue: "", + expected: nil, + }, + { + name: "Only commas and spaces", + queryValue: " , , ", + expected: nil, + }, + { + name: "Mixed empty and valid values", + queryValue: "value1,,value2, ,value3", + expected: []string{"value1", "value2", "value3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{"test_param": []string{tt.queryValue}}.Encode(), + }, + } + + result := ParseStringArrayQueryParam(req, "test_param") + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidatePaginationParams(t *testing.T) { + tests := []struct { + name string + inputLimit int + inputOffset int + expectedLimit int + expectedOffset int + }{ + { + name: "Valid parameters", + inputLimit: 100, + inputOffset: 50, + expectedLimit: 100, + expectedOffset: 50, + }, + { + name: "Zero limit uses default", + inputLimit: 0, + inputOffset: 10, + expectedLimit: 50, + expectedOffset: 10, + }, + { + name: "Negative limit uses default", + inputLimit: -10, + inputOffset: 10, + expectedLimit: 50, + expectedOffset: 10, + }, + { + name: "Limit exceeds max", + inputLimit: 2000, + inputOffset: 10, + expectedLimit: 1000, + expectedOffset: 10, + }, + { + name: "Negative offset uses zero", + inputLimit: 100, + inputOffset: -10, + expectedLimit: 100, + expectedOffset: 0, + }, + { + name: "Both invalid values", + inputLimit: -5, + inputOffset: -10, + expectedLimit: 50, + expectedOffset: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limit, offset := ValidatePaginationParams(tt.inputLimit, tt.inputOffset) + assert.Equal(t, tt.expectedLimit, limit) + assert.Equal(t, tt.expectedOffset, offset) + }) + } +} \ No newline at end of file diff --git a/test/integration/api_test.go b/test/integration/api_test.go index a9dd891..f7688ff 100644 --- a/test/integration/api_test.go +++ b/test/integration/api_test.go @@ -95,6 +95,27 @@ func (m *MockProvinceCaseRepo) GetLatestByProvinceID(provinceID string) (*models return result.(*models.ProvinceCaseWithDate), args.Error(1) } +// Paginated methods +func (m *MockProvinceCaseRepo) GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepo) GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepo) GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepo) GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + func setupTestServer() (*httptest.Server, *MockNationalCaseRepo, *MockProvinceRepo, *MockProvinceCaseRepo) { mockNationalRepo := new(MockNationalCaseRepo) mockProvinceRepo := new(MockProvinceRepo) @@ -236,7 +257,7 @@ func TestAPI_GetLatestNationalCase(t *testing.T) { } func TestAPI_GetProvinces(t *testing.T) { - server, _, mockProvinceRepo, _ := setupTestServer() + server, _, mockProvinceRepo, mockProvinceCaseRepo := setupTestServer() defer server.Close() expectedProvinces := []models.Province{ @@ -244,7 +265,23 @@ func TestAPI_GetProvinces(t *testing.T) { {ID: "31", Name: "DKI Jakarta"}, } + // Mock the calls needed for GetProvincesWithLatestCase (default behavior) mockProvinceRepo.On("GetAll").Return(expectedProvinces, nil) + + // Mock the latest case data for each province + testTime := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + mockProvinceCaseRepo.On("GetLatestByProvinceID", "11").Return(&models.ProvinceCaseWithDate{ + ProvinceCase: models.ProvinceCase{ + ID: 1, ProvinceID: "11", Positive: 10, Day: 100, + }, + Date: testTime, + }, nil) + mockProvinceCaseRepo.On("GetLatestByProvinceID", "31").Return(&models.ProvinceCaseWithDate{ + ProvinceCase: models.ProvinceCase{ + ID: 2, ProvinceID: "31", Positive: 25, Day: 100, + }, + Date: testTime, + }, nil) resp, err := http.Get(server.URL + "/api/v1/provinces") assert.NoError(t, err) @@ -262,6 +299,7 @@ func TestAPI_GetProvinces(t *testing.T) { assert.Equal(t, "success", response.Status) mockProvinceRepo.AssertExpectations(t) + mockProvinceCaseRepo.AssertExpectations(t) } func TestAPI_GetProvinceCases(t *testing.T) { @@ -279,7 +317,7 @@ func TestAPI_GetProvinceCases(t *testing.T) { }, } - mockProvinceCaseRepo.On("GetAll").Return(expectedCases, nil) + mockProvinceCaseRepo.On("GetAllPaginated", 50, 0).Return(expectedCases, len(expectedCases), nil) resp, err := http.Get(server.URL + "/api/v1/provinces/cases") assert.NoError(t, err)