From 3e313c699a1ecb9a0b5a2632c827fc0c5f3b2868 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Sep 2025 07:32:39 +0000 Subject: [PATCH 01/12] chore: bump version to v2.3.0 for next development cycle Following release branch creation for v2.2.0, updating develop branch to target the next minor version v2.3.0. Changes: - Update version annotations to 2.3.0 - Regenerate API documentation - Prepare for next development cycle This maintains the Git Flow pattern where develop always contains the next planned version. --- cmd/main.go | 2 +- docs/docs.go | 2 +- docs/swagger.json | 2 +- docs/swagger.yaml | 2 +- internal/handler/covid_handler.go | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index d329507..9fb8710 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,7 +1,7 @@ // Package main provides the entry point for the Sulawesi Tengah COVID-19 Data API // // @title Sulawesi Tengah COVID-19 Data API -// @version 2.2.0 +// @version 2.3.0 // @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, hybrid pagination, and rate limiting protection. Rate limiting: 100 requests per minute per IP address by default, with appropriate HTTP headers for client guidance. // @termsOfService http://swagger.io/terms/ // diff --git a/docs/docs.go b/docs/docs.go index abbcd00..4fb9523 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -815,7 +815,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "2.2.0", + Version: "2.3.0", Host: "pico-api.banuacoder.com", BasePath: "/api/v1", Schemes: []string{"https", "http"}, diff --git a/docs/swagger.json b/docs/swagger.json index 7e8f02a..66a57ba 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -17,7 +17,7 @@ "name": "MIT", "url": "https://opensource.org/licenses/MIT" }, - "version": "2.2.0" + "version": "2.3.0" }, "host": "pico-api.banuacoder.com", "basePath": "/api/v1", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8a8fe1f..b9c819d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -209,7 +209,7 @@ info: url: https://opensource.org/licenses/MIT termsOfService: http://swagger.io/terms/ title: Sulawesi Tengah COVID-19 Data API - version: 2.2.0 + version: 2.3.0 paths: /: get: diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 35d9c81..458707a 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -298,7 +298,7 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { health := map[string]interface{}{ "status": "healthy", "service": "COVID-19 API", - "version": "2.2.0", + "version": "2.3.0", "timestamp": time.Now().UTC().Format(time.RFC3339), } @@ -355,7 +355,7 @@ 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.2.0", + "version": "2.3.0", "description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi)", }, "documentation": map[string]interface{}{ From c9b77a128a099df7649ed3975a34c41ea897d04f Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 14:57:23 +0700 Subject: [PATCH 02/12] refactor: remove swagger documentation upload from deployment - Remove swagger file upload to server (HTML, JSON, YAML) - Remove swagger documentation generation from deployment workflow - Remove server-side documentation directory handling - Simplify deployment to only handle binary deployment - Keep swagger generation in repo for development/CI purposes only --- .github/workflows/deploy.yml | 56 ------------------------------------ 1 file changed, 56 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 922857d..cea2830 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,27 +39,6 @@ jobs: file ${{ secrets.BINARY_NAME }} echo "Binary size: $(du -h ${{ secrets.BINARY_NAME }} | cut -f1)" - - name: Generate Swagger documentation - run: | - echo "📚 Generating Swagger documentation..." - - # Install swag tool - go install github.com/swaggo/swag/cmd/swag@latest - - # Generate documentation including HTML - swag init -g cmd/main.go -o ./docs --outputTypes go,json,yaml,html - - # Verify generated files - echo "Generated documentation files:" - ls -la docs/ - - # Check if HTML was generated - if [ -f "docs/swagger.html" ]; then - echo "✅ HTML documentation generated successfully" - else - echo "⚠️ HTML documentation not found, continuing without it" - fi - - name: Setup SSH Agent uses: webfactory/ssh-agent@v0.8.0 with: @@ -92,22 +71,6 @@ jobs: echo "📤 Uploading binary..." scp -P ${{ secrets.DEPLOY_PORT }} ${{ secrets.BINARY_NAME }} ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/${{ secrets.BINARY_NAME }}-${{ env.VERSION }} - # Upload documentation files - echo "📚 Uploading documentation..." - if [ -f "docs/swagger.html" ]; then - scp -P ${{ secrets.DEPLOY_PORT }} docs/swagger.html ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/swagger-${{ env.VERSION }}.html - echo "✅ HTML documentation uploaded" - fi - - if [ -f "docs/swagger.json" ]; then - scp -P ${{ secrets.DEPLOY_PORT }} docs/swagger.json ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/swagger-${{ env.VERSION }}.json - echo "✅ JSON documentation uploaded" - fi - - if [ -f "docs/swagger.yaml" ]; then - scp -P ${{ secrets.DEPLOY_PORT }} docs/swagger.yaml ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/swagger-${{ env.VERSION }}.yaml - echo "✅ YAML documentation uploaded" - fi # Execute deployment script on remote server ssh -p ${{ secrets.DEPLOY_PORT }} ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF' @@ -142,25 +105,6 @@ jobs: chmod +x "$BINARY_NAME" echo "✅ Binary replaced and made executable" - echo "📚 Updating documentation..." - # Create docs directory if it doesn't exist - mkdir -p docs - - # Move documentation files if they exist - if [ -f "/tmp/swagger-${VERSION}.html" ]; then - mv "/tmp/swagger-${VERSION}.html" "docs/swagger.html" - echo "✅ HTML documentation updated" - fi - - if [ -f "/tmp/swagger-${VERSION}.json" ]; then - mv "/tmp/swagger-${VERSION}.json" "docs/swagger.json" - echo "✅ JSON documentation updated" - fi - - if [ -f "/tmp/swagger-${VERSION}.yaml" ]; then - mv "/tmp/swagger-${VERSION}.yaml" "docs/swagger.yaml" - echo "✅ YAML documentation updated" - fi echo "🚀 Starting application..." if [ -f "./start.sh" ]; then From 047d8697ad3bb53992a2292fb14b212b5db3bab1 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 15:01:26 +0700 Subject: [PATCH 03/12] fix: add .htaccess to properly route Swagger UI requests to Go app - Create .htaccess configuration for Apache/LiteSpeed servers - Proxy /api/v1/*, /swagger/*, and /docs requests to Go application - Preserve static file serving for root domain and assets - Add CORS headers and security headers - Enable compression and caching for static assets - Fix Swagger UI accessibility at /swagger/index.html - Remove root redirect handler to avoid conflicts with static site --- internal/handler/routes.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/handler/routes.go b/internal/handler/routes.go index a125320..d963efe 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -31,8 +31,8 @@ func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router // 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) { + // Redirect /docs to swagger documentation for convenience + router.HandleFunc("/docs", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/swagger/index.html", http.StatusFound) }).Methods("GET") From 22a243b6c93f55ee8fc3662a3335c2da1ac93414 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 15:42:26 +0700 Subject: [PATCH 04/12] fix: update version bump workflow to include test files - Add test file version updates to release branch creation workflow - Update both release branch and develop branch version bump sections - Include covid_handler_test.go version assertions updates - Fix test version expectations to match current version (2.3.0) - Ensure all version-related files stay in sync during automated bumps --- .github/workflows/release-branch-creation.yml | 14 ++++++++++++++ internal/handler/covid_handler_test.go | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-branch-creation.yml b/.github/workflows/release-branch-creation.yml index f8f5eb4..a71526e 100644 --- a/.github/workflows/release-branch-creation.yml +++ b/.github/workflows/release-branch-creation.yml @@ -278,6 +278,13 @@ jobs: echo "✅ Updated internal/handler/covid_handler.go" fi + # Update test files + if [ -f "internal/handler/covid_handler_test.go" ]; then + sed -i.bak "s/assert.Equal(t, \"[^\"]*\", apiInfo\[\"version\"\])/assert.Equal(t, \"$CLEAN_VERSION\", apiInfo[\"version\"])/" internal/handler/covid_handler_test.go && rm -f internal/handler/covid_handler_test.go.bak + sed -i.bak "s/assert.Equal(t, \"[^\"]*\", data\[\"version\"\])/assert.Equal(t, \"$CLEAN_VERSION\", data[\"version\"])/" internal/handler/covid_handler_test.go && rm -f internal/handler/covid_handler_test.go.bak + echo "✅ Updated internal/handler/covid_handler_test.go" + fi + - name: Install and regenerate documentation run: | echo "📚 Regenerating API documentation..." @@ -553,6 +560,13 @@ jobs: echo "✅ Updated internal/handler/covid_handler.go to $CLEAN_VERSION" fi + # Update test files + if [ -f "internal/handler/covid_handler_test.go" ]; then + sed -i.bak "s/assert.Equal(t, \"[^\"]*\", apiInfo\[\"version\"\])/assert.Equal(t, \"$CLEAN_VERSION\", apiInfo[\"version\"])/" internal/handler/covid_handler_test.go && rm -f internal/handler/covid_handler_test.go.bak + sed -i.bak "s/assert.Equal(t, \"[^\"]*\", data\[\"version\"\])/assert.Equal(t, \"$CLEAN_VERSION\", data[\"version\"])/" internal/handler/covid_handler_test.go && rm -f internal/handler/covid_handler_test.go.bak + echo "✅ Updated internal/handler/covid_handler_test.go to $CLEAN_VERSION" + fi + # Install swag and regenerate docs go install github.com/swaggo/swag/cmd/swag@latest export PATH=$PATH:$(go env GOPATH)/bin diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index ef40e00..4dc6c40 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -607,7 +607,7 @@ func TestCovidHandler_GetAPIIndex(t *testing.T) { 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.2.0", apiInfo["version"]) + assert.Equal(t, "2.3.0", apiInfo["version"]) // Verify endpoints structure endpoints, ok := data["endpoints"].(map[string]interface{}) @@ -638,7 +638,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.2.0", data["version"]) + assert.Equal(t, "2.3.0", data["version"]) assert.Contains(t, data, "database") dbData, ok := data["database"].(map[string]interface{}) From 7f9acb14c50c6e3012588b7459ad4d4c24b81c83 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 18:33:40 +0700 Subject: [PATCH 05/12] refactor: migrate Swagger UI to static files - Remove github.com/swaggo/http-swagger dependency - Serve API spec via /api/v1/swagger.json endpoint - Update .htaccess to serve static Swagger UI files - Reduce binary size from 19MB to 6.8MB (64% reduction) - Maintain same URLs: /swagger/ and /docs redirect --- go.mod | 5 +---- go.sum | 33 --------------------------------- internal/handler/routes.go | 10 ++++++---- 3 files changed, 7 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index 4022130..a49bad7 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ 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 + github.com/swaggo/swag v1.16.6 ) require ( @@ -35,10 +35,7 @@ require ( 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 83d2836..356611c 100644 --- a/go.sum +++ b/go.sum @@ -61,47 +61,14 @@ 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= -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= diff --git a/internal/handler/routes.go b/internal/handler/routes.go index d963efe..18bc1c3 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -6,7 +6,6 @@ import ( "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 { @@ -28,10 +27,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") + // API specification endpoint + api.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + http.ServeFile(w, r, "./docs/swagger.json") + }).Methods("GET", "OPTIONS") - // Redirect /docs to swagger documentation for convenience + // Redirect /docs to static swagger UI router.HandleFunc("/docs", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/swagger/index.html", http.StatusFound) }).Methods("GET") From 49b3f334ffe96bd64216ae645b303b364f1cb1b0 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 18:35:24 +0700 Subject: [PATCH 06/12] feat: update deployment workflow for static Swagger UI - Add API specification deployment step after binary deployment - Generate and upload swagger.json to server docs directory - Add Swagger JSON endpoint verification to health checks - Update deployment summary to reflect optimized binary size - Include Swagger endpoint verification in deployment status --- .github/workflows/deploy.yml | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cea2830..027f7d6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -117,6 +117,24 @@ jobs: echo "🎉 Initial deployment of ${VERSION} completed!" EOF + - name: Deploy API specification + run: | + echo "📚 Deploying API specification for Swagger UI..." + + # Generate latest swagger.json + go install github.com/swaggo/swag/cmd/swag@latest + export PATH=$PATH:$(go env GOPATH)/bin + swag init -g cmd/main.go -o ./docs + + # Upload swagger.json to be served by Go app + if [ -f "docs/swagger.json" ]; then + echo "📤 Uploading swagger.json..." + scp -P ${{ secrets.DEPLOY_PORT }} docs/swagger.json ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/docs/ + echo "✅ API specification deployed" + else + echo "⚠️ swagger.json not found, skipping upload" + fi + - name: Simple health check id: health_check run: | @@ -127,6 +145,16 @@ jobs: echo "📡 Testing health endpoint..." if curl -f -s "${{ secrets.HEALTH_CHECK_URL }}" > /dev/null; then echo "✅ Health check passed - API is responding" + + # Test Swagger JSON endpoint + SWAGGER_URL=$(echo "${{ secrets.HEALTH_CHECK_URL }}" | sed 's|/health|/swagger.json|') + echo "📚 Testing Swagger JSON endpoint: $SWAGGER_URL" + if curl -f -s "$SWAGGER_URL" > /dev/null; then + echo "✅ Swagger JSON endpoint is working" + else + echo "⚠️ Swagger JSON endpoint not accessible" + fi + echo "health_check_passed=true" >> $GITHUB_OUTPUT else echo "⚠️ Health check failed - API not responding" @@ -175,12 +203,14 @@ jobs: echo "- **Target**: ${{ secrets.DEPLOY_PATH }}" >> $GITHUB_STEP_SUMMARY echo "- **Status**: ✅ Deployed successfully" >> $GITHUB_STEP_SUMMARY echo "- **Health Check**: ✅ Passed" >> $GITHUB_STEP_SUMMARY - echo "- **Documentation**: 📚 Updated" >> $GITHUB_STEP_SUMMARY + echo "- **Documentation**: 📚 Static Swagger UI + API Spec" >> $GITHUB_STEP_SUMMARY + echo "- **Binary Size**: 🎯 Optimized (64% smaller)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Completed Actions" >> $GITHUB_STEP_SUMMARY - echo "- ✅ Binary deployed and started" >> $GITHUB_STEP_SUMMARY - echo "- ✅ Documentation updated (HTML, JSON, YAML)" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Binary deployed and started (optimized size)" >> $GITHUB_STEP_SUMMARY + echo "- ✅ API specification (swagger.json) deployed" >> $GITHUB_STEP_SUMMARY echo "- ✅ Health check verification passed" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Swagger JSON endpoint verified" >> $GITHUB_STEP_SUMMARY echo "- ✅ Old backups cleaned up" >> $GITHUB_STEP_SUMMARY else echo "## ❌ Deployment Failed" >> $GITHUB_STEP_SUMMARY From 6c43bb310ee04b0b6b1b17bc5c4512e457e4c71a Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 18:36:25 +0700 Subject: [PATCH 07/12] docs: remove RATE_LIMITING.md documentation file --- RATE_LIMITING.md | 90 ------------------------------------------------ 1 file changed, 90 deletions(-) delete mode 100644 RATE_LIMITING.md diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md deleted file mode 100644 index c185cd6..0000000 --- a/RATE_LIMITING.md +++ /dev/null @@ -1,90 +0,0 @@ -# Rate Limiting - -This API implements rate limiting to ensure fair usage and protect against abuse. The rate limiter uses a sliding window algorithm to track requests per client IP address. - -## Configuration - -Rate limiting can be configured using environment variables: - -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `RATE_LIMIT_ENABLED` | `true` | Enable or disable rate limiting | -| `RATE_LIMIT_REQUESTS_PER_MINUTE` | `100` | Maximum requests per minute per IP | -| `RATE_LIMIT_BURST_SIZE` | `20` | Burst size for initial requests | -| `RATE_LIMIT_WINDOW_SIZE` | `1m` | Time window for rate limiting | - -## Response Headers - -All API responses include the following rate limiting headers: - -- `X-RateLimit-Limit`: The maximum number of requests allowed in the current window -- `X-RateLimit-Remaining`: The number of requests remaining in the current window -- `X-RateLimit-Reset`: Unix timestamp when the rate limit window resets (only on 429 responses) -- `Retry-After`: Number of seconds to wait before making another request (only on 429 responses) - -## Rate Limit Exceeded - -When the rate limit is exceeded, the API returns: - -- **Status Code**: `429 Too Many Requests` -- **Response Body**: - ```json - { - "status": "error", - "error": "Rate limit exceeded. Too many requests." - } - ``` - -## Client IP Detection - -The rate limiter identifies clients by IP address using the following priority: - -1. `X-Forwarded-For` header (for load balancers/proxies) -2. `X-Real-IP` header (for reverse proxies) -3. `RemoteAddr` from the connection (fallback) - -## Implementation Details - -- **Algorithm**: Sliding window rate limiter -- **Storage**: In-memory (per instance) -- **Cleanup**: Automatic cleanup of old client records every 5 minutes -- **Thread Safety**: Fully concurrent with proper mutex locking - -## Best Practices for Clients - -1. **Check Headers**: Always check the `X-RateLimit-*` headers to understand your current quota -2. **Handle 429 Responses**: Implement exponential backoff when receiving 429 responses -3. **Use Retry-After**: Respect the `Retry-After` header value before retrying -4. **Distribute Requests**: Avoid bursting all requests at once; distribute them evenly - -## Example Usage - -```bash -# Check current rate limit status -curl -I https://api.example.com/api/v1/national - -# Response headers will include: -# X-RateLimit-Limit: 100 -# X-RateLimit-Remaining: 99 - -# When rate limited: -# HTTP/1.1 429 Too Many Requests -# X-RateLimit-Limit: 100 -# X-RateLimit-Remaining: 0 -# X-RateLimit-Reset: 1672531200 -# Retry-After: 60 -``` - -## Disabling Rate Limiting - -To disable rate limiting (not recommended for production): - -```bash -export RATE_LIMIT_ENABLED=false -``` - -Or set it in your `.env` file: - -``` -RATE_LIMIT_ENABLED=false -``` \ No newline at end of file From 14cf791c5a177ad32fcf8d3c21f084cf387edfa6 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 18:53:52 +0700 Subject: [PATCH 08/12] fix: handle NULL values in province case database fields - Make nullable database fields properly nullable (*int64) in ProvinceCase model - Add null-safe transformation in ProvinceCaseResponse - Fix SQL scan errors for person_under_observation and related fields - Add page parameter support in ParsePaginationParams utility function Resolves SQL scan error: "converting NULL to int64 is unsupported" --- internal/models/province_case.go | 16 ++++++------ internal/models/province_case_response.go | 30 ++++++++++++++--------- pkg/utils/query.go | 20 +++++++++++++++ 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/internal/models/province_case.go b/internal/models/province_case.go index 7815fd4..4c59f04 100644 --- a/internal/models/province_case.go +++ b/internal/models/province_case.go @@ -9,17 +9,17 @@ type ProvinceCase struct { Positive int64 `json:"positive" db:"positive"` Recovered int64 `json:"recovered" db:"recovered"` Deceased int64 `json:"deceased" db:"deceased"` - PersonUnderObservation int64 `json:"person_under_observation" db:"person_under_observation"` - FinishedPersonUnderObservation int64 `json:"finished_person_under_observation" db:"finished_person_under_observation"` - PersonUnderSupervision int64 `json:"person_under_supervision" db:"person_under_supervision"` - FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision" db:"finished_person_under_supervision"` + PersonUnderObservation *int64 `json:"person_under_observation" db:"person_under_observation"` + FinishedPersonUnderObservation *int64 `json:"finished_person_under_observation" db:"finished_person_under_observation"` + PersonUnderSupervision *int64 `json:"person_under_supervision" db:"person_under_supervision"` + FinishedPersonUnderSupervision *int64 `json:"finished_person_under_supervision" db:"finished_person_under_supervision"` CumulativePositive int64 `json:"cumulative_positive" db:"cumulative_positive"` CumulativeRecovered int64 `json:"cumulative_recovered" db:"cumulative_recovered"` CumulativeDeceased int64 `json:"cumulative_deceased" db:"cumulative_deceased"` - CumulativePersonUnderObservation int64 `json:"cumulative_person_under_observation" db:"cumulative_person_under_observation"` - CumulativeFinishedPersonUnderObservation int64 `json:"cumulative_finished_person_under_observation" db:"cumulative_finished_person_under_observation"` - CumulativePersonUnderSupervision int64 `json:"cumulative_person_under_supervision" db:"cumulative_person_under_supervision"` - CumulativeFinishedPersonUnderSupervision int64 `json:"cumulative_finished_person_under_supervision" db:"cumulative_finished_person_under_supervision"` + CumulativePersonUnderObservation *int64 `json:"cumulative_person_under_observation" db:"cumulative_person_under_observation"` + CumulativeFinishedPersonUnderObservation *int64 `json:"cumulative_finished_person_under_observation" db:"cumulative_finished_person_under_observation"` + CumulativePersonUnderSupervision *int64 `json:"cumulative_person_under_supervision" db:"cumulative_person_under_supervision"` + CumulativeFinishedPersonUnderSupervision *int64 `json:"cumulative_finished_person_under_supervision" db:"cumulative_finished_person_under_supervision"` Rt *float64 `json:"rt" db:"rt"` RtUpper *float64 `json:"rt_upper" db:"rt_upper"` RtLower *float64 `json:"rt_lower" db:"rt_lower"` diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go index 2cfe08c..da3ad08 100644 --- a/internal/models/province_case_response.go +++ b/internal/models/province_case_response.go @@ -70,9 +70,17 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse dailyActive := pc.Positive - pc.Recovered - pc.Deceased cumulativeActive := pc.CumulativePositive - pc.CumulativeRecovered - pc.CumulativeDeceased - // Calculate active under observation and supervision - activePersonUnderObservation := pc.CumulativePersonUnderObservation - pc.CumulativeFinishedPersonUnderObservation - activePersonUnderSupervision := pc.CumulativePersonUnderSupervision - pc.CumulativeFinishedPersonUnderSupervision + // Helper function to safely get int64 value from pointer + safeInt64 := func(ptr *int64) int64 { + if ptr == nil { + return 0 + } + return *ptr + } + + // Calculate active under observation and supervision (with null safety) + activePersonUnderObservation := safeInt64(pc.CumulativePersonUnderObservation) - safeInt64(pc.CumulativeFinishedPersonUnderObservation) + activePersonUnderSupervision := safeInt64(pc.CumulativePersonUnderSupervision) - safeInt64(pc.CumulativeFinishedPersonUnderSupervision) // Build response response := ProvinceCaseResponse{ @@ -84,12 +92,12 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Deceased: pc.Deceased, Active: dailyActive, ODP: DailyObservationData{ - Active: pc.PersonUnderObservation - pc.FinishedPersonUnderObservation, - Finished: pc.FinishedPersonUnderObservation, + Active: safeInt64(pc.PersonUnderObservation) - safeInt64(pc.FinishedPersonUnderObservation), + Finished: safeInt64(pc.FinishedPersonUnderObservation), }, PDP: DailySupervisionData{ - Active: pc.PersonUnderSupervision - pc.FinishedPersonUnderSupervision, - Finished: pc.FinishedPersonUnderSupervision, + Active: safeInt64(pc.PersonUnderSupervision) - safeInt64(pc.FinishedPersonUnderSupervision), + Finished: safeInt64(pc.FinishedPersonUnderSupervision), }, }, Cumulative: ProvinceCumulativeCases{ @@ -99,13 +107,13 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Active: cumulativeActive, ODP: ObservationData{ Active: activePersonUnderObservation, - Finished: pc.CumulativeFinishedPersonUnderObservation, - Total: pc.CumulativePersonUnderObservation, + Finished: safeInt64(pc.CumulativeFinishedPersonUnderObservation), + Total: safeInt64(pc.CumulativePersonUnderObservation), }, PDP: SupervisionData{ Active: activePersonUnderSupervision, - Finished: pc.CumulativeFinishedPersonUnderSupervision, - Total: pc.CumulativePersonUnderSupervision, + Finished: safeInt64(pc.CumulativeFinishedPersonUnderSupervision), + Total: safeInt64(pc.CumulativePersonUnderSupervision), }, }, Statistics: ProvinceCaseStatistics{ diff --git a/pkg/utils/query.go b/pkg/utils/query.go index 734e261..a9bd461 100644 --- a/pkg/utils/query.go +++ b/pkg/utils/query.go @@ -150,3 +150,23 @@ func ValidatePaginationParams(limit, offset int) (int, int) { return limit, offset } + +// ParsePaginationParams parses pagination parameters from request +// Supports both offset-based and page-based pagination +func ParsePaginationParams(r *http.Request) (limit, offset int) { + // Parse limit (records per page) + limit = ParseIntQueryParam(r, "limit", 50) + + // Check if page parameter is provided + page := ParseIntQueryParam(r, "page", 0) + if page > 0 { + // Page-based pagination (page starts from 1) + offset = (page - 1) * limit + } else { + // Offset-based pagination (fallback) + offset = ParseIntQueryParam(r, "offset", 0) + } + + // Validate and adjust parameters + return ValidatePaginationParams(limit, offset) +} From 91028d7289a6a08453a44f87c13349ca300b717f Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:33:49 +0700 Subject: [PATCH 09/12] fix: standardize error handling in province case repository - Consistent error messages for count and query operations - Match patterns from national case repository - Improve error context in paginated methods --- .../repository/province_case_repository.go | 159 ++++++++++++++++-- 1 file changed, 144 insertions(+), 15 deletions(-) diff --git a/internal/repository/province_case_repository.go b/internal/repository/province_case_repository.go index cb7b9f0..add40cc 100644 --- a/internal/repository/province_case_repository.go +++ b/internal/repository/province_case_repository.go @@ -72,7 +72,7 @@ func (r *provinceCaseRepository) GetAllPaginatedSorted(limit, offset int, sortPa 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) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -91,7 +91,7 @@ func (r *provinceCaseRepository) GetAllPaginatedSorted(limit, offset int, sortPa cases, err := r.queryProvinceCases(query, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -123,7 +123,7 @@ func (r *provinceCaseRepository) GetByProvinceIDPaginated(provinceID string, lim 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) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -143,7 +143,7 @@ func (r *provinceCaseRepository) GetByProvinceIDPaginated(provinceID string, lim cases, err := r.queryProvinceCases(query, provinceID, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -175,7 +175,7 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID 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) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -195,7 +195,7 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID cases, err := r.queryProvinceCases(query, provinceID, startDate, endDate, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -227,7 +227,7 @@ func (r *provinceCaseRepository) GetByDateRangePaginated(startDate, endDate time 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) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -247,7 +247,7 @@ func (r *provinceCaseRepository) GetByDateRangePaginated(startDate, endDate time cases, err := r.queryProvinceCases(query, startDate, endDate, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -357,27 +357,156 @@ func (r *provinceCaseRepository) buildOrderClause(sortParams utils.SortParams) s return dbField + " " + order } -// Stub implementations for other sorted methods - delegate to existing methods for now +// Sorted method implementations func (r *provinceCaseRepository) GetByProvinceIDSorted(provinceID string, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { - return r.GetByProvinceID(provinceID) + 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 ` + r.buildOrderClause(sortParams) + + return r.queryProvinceCases(query, provinceID) } func (r *provinceCaseRepository) GetByProvinceIDPaginatedSorted(provinceID string, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { - return r.GetByProvinceIDPaginated(provinceID, limit, offset) + // 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 get total count: %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 pc.province_id = ? + ORDER BY ` + r.buildOrderClause(sortParams) + ` LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) + } + + return cases, total, nil } func (r *provinceCaseRepository) GetByProvinceIDAndDateRangeSorted(provinceID string, startDate, endDate time.Time, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { - return r.GetByProvinceIDAndDateRange(provinceID, startDate, endDate) + 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 ` + r.buildOrderClause(sortParams) + + return r.queryProvinceCases(query, provinceID, startDate, endDate) } func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginatedSorted(provinceID string, startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { - return r.GetByProvinceIDAndDateRangePaginated(provinceID, startDate, endDate, limit, offset) + // 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 get total count: %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 pc.province_id = ? AND nc.date BETWEEN ? AND ? + ORDER BY ` + r.buildOrderClause(sortParams) + ` LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) + } + + return cases, total, nil } func (r *provinceCaseRepository) GetByDateRangeSorted(startDate, endDate time.Time, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { - return r.GetByDateRange(startDate, endDate) + 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 ` + r.buildOrderClause(sortParams) + + return r.queryProvinceCases(query, startDate, endDate) } func (r *provinceCaseRepository) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { - return r.GetByDateRangePaginated(startDate, endDate, limit, offset) + // 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 get total count: %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 ` + r.buildOrderClause(sortParams) + ` LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) + } + + return cases, total, nil } From ecd9047c25571053cbb04e1800613dcba03b533f Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:34:10 +0700 Subject: [PATCH 10/12] fix: handle nullable pointer fields in province case tests - Convert direct integer values to pointer references - Use inline pointer creation for nullable database fields - Update test assertions to dereference pointers properly - Fix compilation errors in model tests --- .../models/province_case_response_test.go | 142 +++++++++++------- internal/models/province_case_test.go | 24 +-- 2 files changed, 98 insertions(+), 68 deletions(-) diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go index 88c832a..5cd00a8 100644 --- a/internal/models/province_case_response_test.go +++ b/internal/models/province_case_response_test.go @@ -13,6 +13,36 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { rtUpper := 1.8 rtLower := 1.2 + // Helper variables for nullable int64 fields (test case 1) + personUnderObservation1 := int64(25) + finishedPersonUnderObservation1 := int64(20) + personUnderSupervision1 := int64(30) + finishedPersonUnderSupervision1 := int64(25) + cumulativePersonUnderObservation1 := int64(800) + cumulativeFinishedPersonUnderObservation1 := int64(750) + cumulativePersonUnderSupervision1 := int64(600) + cumulativeFinishedPersonUnderSupervision1 := int64(580) + + // Helper variables for nullable int64 fields (test case 2) + personUnderObservation2 := int64(15) + finishedPersonUnderObservation2 := int64(10) + personUnderSupervision2 := int64(20) + finishedPersonUnderSupervision2 := int64(15) + cumulativePersonUnderObservation2 := int64(400) + cumulativeFinishedPersonUnderObservation2 := int64(350) + cumulativePersonUnderSupervision2 := int64(300) + cumulativeFinishedPersonUnderSupervision2 := int64(290) + + // Helper variables for nullable int64 fields (test case 3 - zeros) + personUnderObservation3 := int64(0) + finishedPersonUnderObservation3 := int64(0) + personUnderSupervision3 := int64(0) + finishedPersonUnderSupervision3 := int64(0) + cumulativePersonUnderObservation3 := int64(0) + cumulativeFinishedPersonUnderObservation3 := int64(0) + cumulativePersonUnderSupervision3 := int64(0) + cumulativeFinishedPersonUnderSupervision3 := int64(0) + tests := []struct { name string provinceCase ProvinceCase @@ -28,17 +58,17 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Positive: 150, Recovered: 120, Deceased: 10, - PersonUnderObservation: 25, - FinishedPersonUnderObservation: 20, - PersonUnderSupervision: 30, - FinishedPersonUnderSupervision: 25, + PersonUnderObservation: &personUnderObservation1, + FinishedPersonUnderObservation: &finishedPersonUnderObservation1, + PersonUnderSupervision: &personUnderSupervision1, + FinishedPersonUnderSupervision: &finishedPersonUnderSupervision1, CumulativePositive: 5000, CumulativeRecovered: 4500, CumulativeDeceased: 300, - CumulativePersonUnderObservation: 800, - CumulativeFinishedPersonUnderObservation: 750, - CumulativePersonUnderSupervision: 600, - CumulativeFinishedPersonUnderSupervision: 580, + CumulativePersonUnderObservation: &cumulativePersonUnderObservation1, + CumulativeFinishedPersonUnderObservation: &cumulativeFinishedPersonUnderObservation1, + CumulativePersonUnderSupervision: &cumulativePersonUnderSupervision1, + CumulativeFinishedPersonUnderSupervision: &cumulativeFinishedPersonUnderSupervision1, Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -108,17 +138,17 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Positive: 100, Recovered: 80, Deceased: 5, - PersonUnderObservation: 15, - FinishedPersonUnderObservation: 10, - PersonUnderSupervision: 20, - FinishedPersonUnderSupervision: 15, + PersonUnderObservation: &personUnderObservation2, + FinishedPersonUnderObservation: &finishedPersonUnderObservation2, + PersonUnderSupervision: &personUnderSupervision2, + FinishedPersonUnderSupervision: &finishedPersonUnderSupervision2, CumulativePositive: 2000, CumulativeRecovered: 1800, CumulativeDeceased: 100, - CumulativePersonUnderObservation: 400, - CumulativeFinishedPersonUnderObservation: 350, - CumulativePersonUnderSupervision: 300, - CumulativeFinishedPersonUnderSupervision: 290, + CumulativePersonUnderObservation: &cumulativePersonUnderObservation2, + CumulativeFinishedPersonUnderObservation: &cumulativeFinishedPersonUnderObservation2, + CumulativePersonUnderSupervision: &cumulativePersonUnderSupervision2, + CumulativeFinishedPersonUnderSupervision: &cumulativeFinishedPersonUnderSupervision2, Rt: nil, RtUpper: nil, RtLower: nil, @@ -188,17 +218,17 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Positive: 0, Recovered: 0, Deceased: 0, - PersonUnderObservation: 0, - FinishedPersonUnderObservation: 0, - PersonUnderSupervision: 0, - FinishedPersonUnderSupervision: 0, + PersonUnderObservation: &personUnderObservation3, + FinishedPersonUnderObservation: &finishedPersonUnderObservation3, + PersonUnderSupervision: &personUnderSupervision3, + FinishedPersonUnderSupervision: &finishedPersonUnderSupervision3, CumulativePositive: 0, CumulativeRecovered: 0, CumulativeDeceased: 0, - CumulativePersonUnderObservation: 0, - CumulativeFinishedPersonUnderObservation: 0, - CumulativePersonUnderSupervision: 0, - CumulativeFinishedPersonUnderSupervision: 0, + CumulativePersonUnderObservation: &cumulativePersonUnderObservation3, + CumulativeFinishedPersonUnderObservation: &cumulativeFinishedPersonUnderObservation3, + CumulativePersonUnderSupervision: &cumulativePersonUnderSupervision3, + CumulativeFinishedPersonUnderSupervision: &cumulativeFinishedPersonUnderSupervision3, Rt: nil, RtUpper: nil, RtLower: nil, @@ -283,17 +313,17 @@ func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { Positive: 50, Recovered: 40, Deceased: 2, - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 12, - FinishedPersonUnderSupervision: 10, + PersonUnderObservation: &[]int64{10}[0], + FinishedPersonUnderObservation: &[]int64{8}[0], + PersonUnderSupervision: &[]int64{12}[0], + FinishedPersonUnderSupervision: &[]int64{10}[0], CumulativePositive: 3000, CumulativeRecovered: 2700, CumulativeDeceased: 200, - CumulativePersonUnderObservation: 500, - CumulativeFinishedPersonUnderObservation: 450, - CumulativePersonUnderSupervision: 350, - CumulativeFinishedPersonUnderSupervision: 320, + CumulativePersonUnderObservation: &[]int64{500}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{450}[0], + CumulativePersonUnderSupervision: &[]int64{350}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{320}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -377,17 +407,17 @@ func TestTransformProvinceCaseSliceToResponse(t *testing.T) { Positive: 100, Recovered: 80, Deceased: 5, - PersonUnderObservation: 20, - FinishedPersonUnderObservation: 15, - PersonUnderSupervision: 25, - FinishedPersonUnderSupervision: 20, + PersonUnderObservation: &[]int64{20}[0], + FinishedPersonUnderObservation: &[]int64{15}[0], + PersonUnderSupervision: &[]int64{25}[0], + FinishedPersonUnderSupervision: &[]int64{20}[0], CumulativePositive: 1000, CumulativeRecovered: 800, CumulativeDeceased: 50, - CumulativePersonUnderObservation: 200, - CumulativeFinishedPersonUnderObservation: 180, - CumulativePersonUnderSupervision: 250, - CumulativeFinishedPersonUnderSupervision: 230, + CumulativePersonUnderObservation: &[]int64{200}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{180}[0], + CumulativePersonUnderSupervision: &[]int64{250}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{230}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -406,17 +436,17 @@ func TestTransformProvinceCaseSliceToResponse(t *testing.T) { Positive: 50, Recovered: 45, Deceased: 2, - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 12, - FinishedPersonUnderSupervision: 10, + PersonUnderObservation: &[]int64{10}[0], + FinishedPersonUnderObservation: &[]int64{8}[0], + PersonUnderSupervision: &[]int64{12}[0], + FinishedPersonUnderSupervision: &[]int64{10}[0], CumulativePositive: 1050, CumulativeRecovered: 845, CumulativeDeceased: 52, - CumulativePersonUnderObservation: 210, - CumulativeFinishedPersonUnderObservation: 188, - CumulativePersonUnderSupervision: 262, - CumulativeFinishedPersonUnderSupervision: 240, + CumulativePersonUnderObservation: &[]int64{210}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{188}[0], + CumulativePersonUnderSupervision: &[]int64{262}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{240}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -468,17 +498,17 @@ func TestProvinceCaseResponse_JSONStructure(t *testing.T) { Positive: 150, Recovered: 120, Deceased: 10, - PersonUnderObservation: 25, - FinishedPersonUnderObservation: 20, - PersonUnderSupervision: 30, - FinishedPersonUnderSupervision: 25, + PersonUnderObservation: &[]int64{25}[0], + FinishedPersonUnderObservation: &[]int64{20}[0], + PersonUnderSupervision: &[]int64{30}[0], + FinishedPersonUnderSupervision: &[]int64{25}[0], CumulativePositive: 5000, CumulativeRecovered: 4500, CumulativeDeceased: 300, - CumulativePersonUnderObservation: 800, - CumulativeFinishedPersonUnderObservation: 750, - CumulativePersonUnderSupervision: 600, - CumulativeFinishedPersonUnderSupervision: 580, + CumulativePersonUnderObservation: &[]int64{800}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{750}[0], + CumulativePersonUnderSupervision: &[]int64{600}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{580}[0], Rt: &rt, RtUpper: &rt, RtLower: &rt, diff --git a/internal/models/province_case_test.go b/internal/models/province_case_test.go index b8071af..a5b477b 100644 --- a/internal/models/province_case_test.go +++ b/internal/models/province_case_test.go @@ -19,17 +19,17 @@ func TestProvinceCase_Structure(t *testing.T) { Positive: 50, Recovered: 40, Deceased: 2, - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 5, - FinishedPersonUnderSupervision: 3, + PersonUnderObservation: &[]int64{10}[0], + FinishedPersonUnderObservation: &[]int64{8}[0], + PersonUnderSupervision: &[]int64{5}[0], + FinishedPersonUnderSupervision: &[]int64{3}[0], CumulativePositive: 500, CumulativeRecovered: 400, CumulativeDeceased: 20, - CumulativePersonUnderObservation: 100, - CumulativeFinishedPersonUnderObservation: 80, - CumulativePersonUnderSupervision: 50, - CumulativeFinishedPersonUnderSupervision: 30, + CumulativePersonUnderObservation: &[]int64{100}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{80}[0], + CumulativePersonUnderSupervision: &[]int64{50}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{30}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -42,10 +42,10 @@ func TestProvinceCase_Structure(t *testing.T) { assert.Equal(t, int64(50), provinceCase.Positive) assert.Equal(t, int64(40), provinceCase.Recovered) assert.Equal(t, int64(2), provinceCase.Deceased) - assert.Equal(t, int64(10), provinceCase.PersonUnderObservation) - assert.Equal(t, int64(8), provinceCase.FinishedPersonUnderObservation) - assert.Equal(t, int64(5), provinceCase.PersonUnderSupervision) - assert.Equal(t, int64(3), provinceCase.FinishedPersonUnderSupervision) + assert.Equal(t, int64(10), *provinceCase.PersonUnderObservation) + assert.Equal(t, int64(8), *provinceCase.FinishedPersonUnderObservation) + assert.Equal(t, int64(5), *provinceCase.PersonUnderSupervision) + assert.Equal(t, int64(3), *provinceCase.FinishedPersonUnderSupervision) assert.Equal(t, int64(500), provinceCase.CumulativePositive) assert.Equal(t, int64(400), provinceCase.CumulativeRecovered) assert.Equal(t, int64(20), provinceCase.CumulativeDeceased) From f96597642de72545f700ad7f800ff1e981a4f7c6 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:34:27 +0700 Subject: [PATCH 11/12] feat: add national cases pagination support - Implement paginated methods in national case repository - Add pagination support to COVID service layer - Update handlers with pagination parameters and responses - Add comprehensive integration tests for paginated endpoints - Maintain backward compatibility with all=true parameter --- internal/handler/covid_handler.go | 65 ++++++++++--- internal/handler/covid_handler_test.go | 83 ++++++++++++++++- .../repository/national_case_repository.go | 93 +++++++++++++++++++ internal/service/covid_service.go | 28 ++++++ internal/service/covid_service_test.go | 10 ++ test/integration/api_test.go | 59 +++++++++++- 6 files changed, 319 insertions(+), 19 deletions(-) diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 458707a..f1de751 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -26,14 +26,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 and sorting +// @Description Retrieve national COVID-19 cases data with optional date range filtering, sorting, and pagination // @Tags national // @Accept json // @Produce json +// @Param limit query integer false "Records per page (default: 50, max: 1000)" +// @Param offset query integer false "Records to skip (default: 0)" +// @Param page query integer false "Page number (1-based, alternative to offset)" +// @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)" // @Param sort query string false "Sort by field:order (e.g., date:desc, positive:asc). Default: date:asc" -// @Success 200 {object} Response{data=[]models.NationalCaseResponse} +// @Success 200 {object} Response{data=models.PaginatedResponse{data=[]models.NationalCaseResponse}} "Paginated response" +// @Success 200 {object} Response{data=[]models.NationalCaseResponse} "All data response when all=true" // @Failure 400 {object} Response // @Failure 429 {object} Response "Rate limit exceeded" // @Failure 500 {object} Response @@ -45,31 +50,66 @@ func NewCovidHandler(covidService service.CovidService, db *database.DB) *CovidH func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) { startDate := r.URL.Query().Get("start_date") endDate := r.URL.Query().Get("end_date") + all := utils.ParseBoolQueryParam(r, "all") // Parse sort parameters (default: date ascending) sortParams := utils.ParseSortParam(r, "date") - if startDate != "" && endDate != "" { - cases, err := h.covidService.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams) + // Handle pagination parameters + limit, offset := utils.ParsePaginationParams(r) + + // Return all data without pagination if "all" is true + if all { + if startDate != "" && endDate != "" { + cases, err := h.covidService.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformSliceToResponse(cases) + writeSuccessResponse(w, responseData) + return + } + + cases, err := h.covidService.GetNationalCasesSorted(sortParams) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformSliceToResponse(cases) writeSuccessResponse(w, responseData) return } - cases, err := h.covidService.GetNationalCasesSorted(sortParams) + // Return paginated data + if startDate != "" && endDate != "" { + cases, total, err := h.covidService.GetNationalCasesByDateRangePaginatedSorted(startDate, endDate, limit, offset, sortParams) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformSliceToResponse(cases) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) + return + } + + cases, total, err := h.covidService.GetNationalCasesPaginatedSorted(limit, offset, sortParams) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - - // Transform to new response structure responseData := models.TransformSliceToResponse(cases) - writeSuccessResponse(w, responseData) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) } // GetLatestNationalCase godoc @@ -145,6 +185,7 @@ func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { // @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 page query integer false "Page number (1-based, alternative to offset)" // @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)" @@ -160,8 +201,7 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) provinceID := vars["provinceId"] // Parse query parameters - limit := utils.ParseIntQueryParam(r, "limit", 50) - offset := utils.ParseIntQueryParam(r, "offset", 0) + limit, offset := utils.ParsePaginationParams(r) all := utils.ParseBoolQueryParam(r, "all") startDate := r.URL.Query().Get("start_date") endDate := r.URL.Query().Get("end_date") @@ -169,9 +209,6 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) // Parse sort parameters (default: date ascending) sortParams := utils.ParseSortParam(r, "date") - // Validate pagination params - limit, offset = utils.ValidatePaginationParams(limit, offset) - if provinceID == "" { // Handle all provinces cases if all { diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index 4dc6c40..f2161a6 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -140,6 +140,16 @@ func (m *MockCovidService) GetAllProvinceCasesByDateRangePaginatedSorted(startDa return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) } +func (m *MockCovidService) GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(startDate, endDate, limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + func TestCovidHandler_GetNationalCases(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) @@ -150,7 +160,7 @@ func TestCovidHandler_GetNationalCases(t *testing.T) { mockService.On("GetNationalCasesSorted", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - req, err := http.NewRequest("GET", "/api/v1/national", nil) + req, err := http.NewRequest("GET", "/api/v1/national?all=true", nil) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -177,7 +187,7 @@ func TestCovidHandler_GetNationalCases_WithDateRange(t *testing.T) { mockService.On("GetNationalCasesByDateRangeSorted", "2020-03-01", "2020-03-31", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31", nil) + req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31&all=true", nil) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -199,7 +209,7 @@ func TestCovidHandler_GetNationalCases_ServiceError(t *testing.T) { mockService.On("GetNationalCasesSorted", utils.SortParams{Field: "date", Order: "asc"}).Return([]models.NationalCase{}, errors.New("database error")) - req, err := http.NewRequest("GET", "/api/v1/national", nil) + req, err := http.NewRequest("GET", "/api/v1/national?all=true", nil) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -216,6 +226,73 @@ func TestCovidHandler_GetNationalCases_ServiceError(t *testing.T) { mockService.AssertExpectations(t) } +func TestCovidHandler_GetNationalCases_Paginated(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.NationalCase{ + {ID: 1, Positive: 100, Recovered: 80, Deceased: 5}, + {ID: 2, Positive: 110, Recovered: 85, Deceased: 6}, + } + + mockService.On("GetNationalCasesPaginatedSorted", 10, 0, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, 2, nil) + + req, err := http.NewRequest("GET", "/api/v1/national?limit=10", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetNationalCases(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) + assert.NotNil(t, response.Data) + + // Check that it's a paginated response + 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_GetNationalCases_WithDateRangePaginated(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.NationalCase{ + {ID: 1, Positive: 100, Date: time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)}, + } + + mockService.On("GetNationalCasesByDateRangePaginatedSorted", "2020-03-01", "2020-03-31", 20, 10, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, 1, nil) + + req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31&limit=20&offset=10", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetNationalCases(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) + assert.NotNil(t, response.Data) + + // Check that it's a paginated response + 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_GetLatestNationalCase(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) diff --git a/internal/repository/national_case_repository.go b/internal/repository/national_case_repository.go index 52147b7..e21f5b4 100644 --- a/internal/repository/national_case_repository.go +++ b/internal/repository/national_case_repository.go @@ -13,8 +13,10 @@ import ( type NationalCaseRepository interface { GetAll() ([]models.NationalCase, error) GetAllSorted(sortParams utils.SortParams) ([]models.NationalCase, error) + GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) GetByDateRange(startDate, endDate time.Time) ([]models.NationalCase, error) GetByDateRangeSorted(startDate, endDate time.Time, sortParams utils.SortParams) ([]models.NationalCase, error) + GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) GetLatest() (*models.NationalCase, error) GetByDay(day int64) (*models.NationalCase, error) } @@ -150,3 +152,94 @@ func (r *nationalCaseRepository) GetByDay(day int64) (*models.NationalCase, erro return &c, nil } + +func (r *nationalCaseRepository) GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + // First get the total count + countQuery := `SELECT COUNT(*) FROM national_cases` + var total int + err := r.db.QueryRow(countQuery).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // Get paginated data + query := `SELECT id, day, date, positive, recovered, deceased, + cumulative_positive, cumulative_recovered, cumulative_deceased, + rt, rt_upper, rt_lower + FROM national_cases + ORDER BY ` + sortParams.GetSQLOrderClause() + ` LIMIT ? OFFSET ?` + + rows, err := r.db.Query(query, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated national cases: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() + + var cases []models.NationalCase + for rows.Next() { + var c models.NationalCase + err := rows.Scan(&c.ID, &c.Day, &c.Date, &c.Positive, &c.Recovered, &c.Deceased, + &c.CumulativePositive, &c.CumulativeRecovered, &c.CumulativeDeceased, + &c.Rt, &c.RtUpper, &c.RtLower) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan national case: %w", err) + } + cases = append(cases, c) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("row iteration error: %w", err) + } + + return cases, total, nil +} + +func (r *nationalCaseRepository) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + // First get the total count + countQuery := `SELECT COUNT(*) FROM national_cases WHERE 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 get total count: %w", err) + } + + // Get paginated data + query := `SELECT id, day, date, positive, recovered, deceased, + cumulative_positive, cumulative_recovered, cumulative_deceased, + rt, rt_upper, rt_lower + FROM national_cases + WHERE date BETWEEN ? AND ? + ORDER BY ` + sortParams.GetSQLOrderClause() + ` LIMIT ? OFFSET ?` + + rows, err := r.db.Query(query, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated national cases by date range: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() + + var cases []models.NationalCase + for rows.Next() { + var c models.NationalCase + err := rows.Scan(&c.ID, &c.Day, &c.Date, &c.Positive, &c.Recovered, &c.Deceased, + &c.CumulativePositive, &c.CumulativeRecovered, &c.CumulativeDeceased, + &c.Rt, &c.RtUpper, &c.RtLower) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan national case: %w", err) + } + cases = append(cases, c) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("row iteration error: %w", err) + } + + return cases, total, nil +} diff --git a/internal/service/covid_service.go b/internal/service/covid_service.go index 12db009..23fca3d 100644 --- a/internal/service/covid_service.go +++ b/internal/service/covid_service.go @@ -14,6 +14,8 @@ type CovidService interface { GetNationalCasesSorted(sortParams utils.SortParams) ([]models.NationalCase, error) GetNationalCasesByDateRange(startDate, endDate string) ([]models.NationalCase, error) GetNationalCasesByDateRangeSorted(startDate, endDate string, sortParams utils.SortParams) ([]models.NationalCase, error) + GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) + GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) GetLatestNationalCase() (*models.NationalCase, error) GetProvinces() ([]models.Province, error) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) @@ -358,3 +360,29 @@ func (s *covidService) GetProvinceCasesByDateRangePaginatedSorted(provinceID, st } return cases, total, nil } + +func (s *covidService) GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + cases, total, err := s.nationalCaseRepo.GetAllPaginatedSorted(limit, offset, sortParams) + if err != nil { + return nil, 0, fmt.Errorf("failed to get sorted national cases paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, 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.nationalCaseRepo.GetByDateRangePaginatedSorted(start, end, limit, offset, sortParams) + if err != nil { + return nil, 0, fmt.Errorf("failed to get sorted national cases by date range paginated: %w", err) + } + return cases, total, nil +} diff --git a/internal/service/covid_service_test.go b/internal/service/covid_service_test.go index 117352a..c1f6962 100644 --- a/internal/service/covid_service_test.go +++ b/internal/service/covid_service_test.go @@ -49,6 +49,16 @@ func (m *MockNationalCaseRepository) GetByDateRangeSorted(startDate, endDate tim return args.Get(0).([]models.NationalCase), args.Error(1) } +func (m *MockNationalCaseRepository) GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + +func (m *MockNationalCaseRepository) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(startDate, endDate, limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + type MockProvinceRepository struct { mock.Mock } diff --git a/test/integration/api_test.go b/test/integration/api_test.go index 3377975..2d28831 100644 --- a/test/integration/api_test.go +++ b/test/integration/api_test.go @@ -54,6 +54,16 @@ func (m *MockNationalCaseRepo) GetByDateRangeSorted(startDate, endDate time.Time return args.Get(0).([]models.NationalCase), args.Error(1) } +func (m *MockNationalCaseRepo) GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + +func (m *MockNationalCaseRepo) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(startDate, endDate, limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + type MockProvinceRepo struct { mock.Mock } @@ -229,7 +239,7 @@ func TestAPI_GetNationalCases(t *testing.T) { mockNationalRepo.On("GetAllSorted", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - resp, err := http.Get(server.URL + "/api/v1/national") + resp, err := http.Get(server.URL + "/api/v1/national?all=true") assert.NoError(t, err) defer func() { if err := resp.Body.Close(); err != nil { @@ -260,7 +270,45 @@ func TestAPI_GetNationalCasesWithDateRange(t *testing.T) { mockNationalRepo.On("GetByDateRangeSorted", startDate, endDate, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - resp, err := http.Get(server.URL + "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31") + resp, err := http.Get(server.URL + "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31&all=true") + assert.NoError(t, err) + defer func() { + if err := resp.Body.Close(); err != nil { + t.Logf("Error closing response body: %v", err) + } + }() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response handler.Response + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + mockNationalRepo.AssertExpectations(t) +} + +func TestAPI_GetNationalCasesPaginated(t *testing.T) { + server, mockNationalRepo, _, _ := setupTestServer() + defer server.Close() + + now := time.Now() + rt := 1.2 + expectedCases := []models.NationalCase{ + { + ID: 1, + Day: 1, + Date: now, + Positive: 100, + Recovered: 80, + Deceased: 5, + Rt: &rt, + }, + } + + mockNationalRepo.On("GetAllPaginatedSorted", 20, 0, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, 1, nil) + + resp, err := http.Get(server.URL + "/api/v1/national?limit=20") assert.NoError(t, err) defer func() { if err := resp.Body.Close(); err != nil { @@ -274,6 +322,13 @@ func TestAPI_GetNationalCasesWithDateRange(t *testing.T) { err = json.NewDecoder(resp.Body).Decode(&response) assert.NoError(t, err) assert.Equal(t, "success", response.Status) + assert.NotNil(t, response.Data) + + // Verify it's a paginated response + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") mockNationalRepo.AssertExpectations(t) } From 554ed300659a66ff1e2d0b77c1fd5be6aa9ab46a Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:34:42 +0700 Subject: [PATCH 12/12] style: add missing newlines at end of files - Add newline to LICENSE file - Add newline to Makefile - Add newline to generate-changelog.rb --- LICENSE | 2 +- Makefile | 3 ++- generate-changelog.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index a86baf9..59ad580 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ 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 +SOFTWARE. diff --git a/Makefile b/Makefile index 0863305..a414e30 100644 --- a/Makefile +++ b/Makefile @@ -81,4 +81,5 @@ help: @echo " dev - Run development server with hot reload" @echo " bench - Run benchmarks" @echo " security - Check for vulnerabilities" - @echo " help - Show this help message" \ No newline at end of file + @echo " help - Show this help message" + \ No newline at end of file diff --git a/generate-changelog.rb b/generate-changelog.rb index c649852..85bd29b 100755 --- a/generate-changelog.rb +++ b/generate-changelog.rb @@ -752,4 +752,4 @@ def self.run(args = ARGV) # Run the CLI if this file is executed directly if __FILE__ == $0 CLI.run -end \ No newline at end of file +end