diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2945b0..e5d5662 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: run: go build ./... - name: Run tests - run: go test ./... -json > TestResults-.json + run: go test ./... -json > TestResults.json - name: Upload test results uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 72699f7..ef65ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /*.csv +/.idea/ +/*.iml \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index ffaed13..fd9337a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,8 +3,8 @@ package main import ( "air-pollution-service/config" "air-pollution-service/internal/csv" - "air-pollution-service/internal/repository" "air-pollution-service/internal/resource" + "air-pollution-service/internal/store" "flag" "fmt" "github.com/go-chi/chi/v5" @@ -17,7 +17,7 @@ import ( func main() { c := config.New() - repo, err := repository.New(csv.New(c.AirPollutionFile)) + repo, err := store.New(csv.New(c.AirPollutionFile)) if err != nil { log.Panic(err) } @@ -32,8 +32,8 @@ func main() { r.Use(middleware.URLFormat) r.Use(render.SetContentType(render.ContentTypeJSON)) - r.Mount("/countries", resource.CountryResource{Repository: repo}.Routes()) - r.Mount("/emissions", resource.EmissionResource{Repository: repo}.Routes()) + r.Mount("/countries", resource.CountryResource{Storage: repo}.Routes()) + r.Mount("/emissions", resource.EmissionResource{Storage: repo}.Routes()) err = http.ListenAndServe(fmt.Sprintf(":%d", c.Server.Port), r) if err != nil { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 183bcf4..7a8d95e 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -3,5 +3,5 @@ package main import "testing" func TestMain(m *testing.M) { - // TODO + // TODO add tests } diff --git a/go.mod b/go.mod index 50a28b1..ef7e2b6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module air-pollution-service -go 1.22 +go 1.22.0 + +toolchain go1.22.5 require ( github.com/go-chi/chi/v5 v5.1.0 @@ -8,6 +10,12 @@ require ( github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd github.com/montanaflynn/stats v0.7.1 + github.com/stretchr/testify v1.9.0 ) -require github.com/ajg/form v1.5.1 // indirect +require ( + github.com/ajg/form v1.5.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b8564d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +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-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= +github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd h1:nIzoSW6OhhppWLm4yqBwZsKJlAayUu5FGozhrF3ETSM= +github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd/go.mod h1:MEQrHur0g8VplbLOv5vXmDzacSaH9Z7XhcgsSh1xciU= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +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/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/http-request-emissions.http b/http-request-emissions.http index cf6531c..bdfab36 100644 --- a/http-request-emissions.http +++ b/http-request-emissions.http @@ -28,4 +28,16 @@ GET {{url}}:{{port}}/emissions/year/ # } # ... # } -GET {{url}}:{{port}}/emissions/country/ \ No newline at end of file +GET {{url}}:{{port}}/emissions/country/ + +### GET all emissions of a single country +# expected response: +# { +# "nox_emissions": { +# "average": 1597043.105081651, +# "median": 71152.08, +# "standard_deviation": 8007847.335584437 +# }, +# ... +# } +GET {{url}}:{{port}}/emissions/country/Germany \ No newline at end of file diff --git a/internal/csv/csv_test.go b/internal/csv/csv_test.go index 5dff9ee..1e7edb6 100644 --- a/internal/csv/csv_test.go +++ b/internal/csv/csv_test.go @@ -3,5 +3,5 @@ package csv import "testing" func TestCSV(t *testing.T) { - // TODO + // TODO add tests } diff --git a/internal/resource/countries.go b/internal/resource/countries.go index e663fbb..ac798a2 100644 --- a/internal/resource/countries.go +++ b/internal/resource/countries.go @@ -2,7 +2,7 @@ package resource import ( "air-pollution-service/internal/model" - "air-pollution-service/internal/repository" + "air-pollution-service/internal/store" "fmt" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -10,7 +10,7 @@ import ( ) type CountryResource struct { - *repository.Repository + Storage store.Storage } type countryResponse struct { @@ -42,7 +42,7 @@ func (rs CountryResource) Routes() chi.Router { } func (rs CountryResource) List(w http.ResponseWriter, r *http.Request) { - countries := rs.Repository.GetCountries() + countries := rs.Storage.GetCountries() if countries == nil { if err := render.Render(w, r, ErrRender(fmt.Sprintf("No country found"), 404)); err != nil { render.Status(r, 500) @@ -71,7 +71,7 @@ func (rs CountryResource) Get(w http.ResponseWriter, r *http.Request) { return } - country := rs.Repository.GetCountry(name) + country := rs.Storage.GetCountry(name) if country == nil { if err := render.Render(w, r, ErrRender(fmt.Sprintf("No country with name %s found", name), 404)); err != nil { render.Status(r, 500) diff --git a/internal/resource/countries_test.go b/internal/resource/countries_test.go index 2a3dc5f..6fc00b0 100644 --- a/internal/resource/countries_test.go +++ b/internal/resource/countries_test.go @@ -1,7 +1,81 @@ package resource -import "testing" +import ( + "air-pollution-service/internal/model" + "context" + "encoding/json" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) -func TestCountries(t *testing.T) { - // TODO +type fakeCountryStorage struct { + countries []*model.Country +} + +func (s fakeCountryStorage) FindAllByYears() map[int][]*model.Emissions { + return nil +} + +func (s fakeCountryStorage) FindAllByCountries() map[string][]*model.Emissions { + return nil +} + +func (s fakeCountryStorage) FindAllByCountry(name string) map[int]*model.Emissions { + return nil +} + +func (s fakeCountryStorage) GetCountry(name string) *model.Country { + if len(s.countries) == 0 { + return nil + } + return s.countries[0] +} + +func (s fakeCountryStorage) GetCountries() []*model.Country { + return s.countries +} + +func TestCountriesGetNonExisting(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := chi.NewRouteContext() + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, ctx)) + ctx.URLParams.Add("name", "Schlaraffenland") + countryHandler := CountryResource{Storage: fakeCountryStorage{[]*model.Country{}}} + + countryHandler.Get(w, req) + res := w.Result() + defer res.Body.Close() + _, err := ioutil.ReadAll(res.Body) + assert.Nil(t, err) + assert.Equal(t, 404, res.StatusCode) +} + +func TestCountriesGetExisting(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := chi.NewRouteContext() + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, ctx)) + ctx.URLParams.Add("name", "Schlaraffenland") + countryHandler := CountryResource{Storage: fakeCountryStorage{[]*model.Country{{ + Name: "Schlaraffenland", + Code: "SCH", + }}}} + + countryHandler.Get(w, req) + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + assert.Nil(t, err) + assert.Equal(t, 200, res.StatusCode) + + country := countryResponse{} + err = json.Unmarshal(data, &country) + assert.Nil(t, err) + assert.Equal(t, "Schlaraffenland", country.Name) + assert.Equal(t, "SCH", country.Code) } diff --git a/internal/resource/emissions.go b/internal/resource/emissions.go index e507025..abf1422 100644 --- a/internal/resource/emissions.go +++ b/internal/resource/emissions.go @@ -2,8 +2,9 @@ package resource import ( "air-pollution-service/internal/model" - "air-pollution-service/internal/repository" + "air-pollution-service/internal/store" "encoding/json" + "fmt" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/montanaflynn/stats" @@ -11,7 +12,7 @@ import ( ) type EmissionResource struct { - *repository.Repository + Storage store.Storage } type airPollutionResponse struct { @@ -89,6 +90,7 @@ func (rs EmissionResource) Routes() chi.Router { r.Route("/country/", func(r chi.Router) { r.Get("/", rs.ListByCountry) + r.Get("/{name}", rs.GetByCountry) }) return r @@ -96,7 +98,7 @@ func (rs EmissionResource) Routes() chi.Router { func (rs EmissionResource) ListByYear(w http.ResponseWriter, r *http.Request) { response := make(map[int]airPollutionEmissionsResponse) - for year, emissions := range rs.FindAllByYears() { + for year, emissions := range rs.Storage.FindAllByYears() { response[year] = newAirPollutionEmissionsResponse(emissions) } @@ -108,7 +110,7 @@ func (rs EmissionResource) ListByYear(w http.ResponseWriter, r *http.Request) { func (rs EmissionResource) ListByCountry(w http.ResponseWriter, r *http.Request) { response := make(map[string]airPollutionEmissionsResponse) - for country, emissions := range rs.FindAllByCountries() { + for country, emissions := range rs.Storage.FindAllByCountries() { response[country] = newAirPollutionEmissionsResponse(emissions) } @@ -117,3 +119,23 @@ func (rs EmissionResource) ListByCountry(w http.ResponseWriter, r *http.Request) render.Status(r, 500) } } + +func (rs EmissionResource) GetByCountry(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if name == "" { + if err := render.Render(w, r, ErrRender(fmt.Sprintf("Country name missing"), 400)); err != nil { + render.Status(r, 500) + } + return + } + + var countryEmissions []*model.Emissions + for _, emissions := range rs.Storage.FindAllByCountry(name) { + countryEmissions = append(countryEmissions, emissions) + } + + response := newAirPollutionEmissionsResponse(countryEmissions) + if err := render.Render(w, r, response); err != nil { + render.Status(r, 500) + } +} diff --git a/internal/resource/emissions_test.go b/internal/resource/emissions_test.go index e6dcfdb..90b398b 100644 --- a/internal/resource/emissions_test.go +++ b/internal/resource/emissions_test.go @@ -1,7 +1,120 @@ package resource -import "testing" +import ( + "air-pollution-service/internal/model" + "context" + "encoding/json" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) -func TestEmissions(t *testing.T) { - // TODO +type fakeEmissionsStorage struct { + emissions []*model.Emissions +} + +func (s fakeEmissionsStorage) FindAllByYears() map[int][]*model.Emissions { + result := make(map[int][]*model.Emissions) + result[1234] = s.emissions + return result +} + +func (s fakeEmissionsStorage) FindAllByCountries() map[string][]*model.Emissions { + result := make(map[string][]*model.Emissions) + result["test"] = s.emissions + return result +} + +func (s fakeEmissionsStorage) FindAllByCountry(name string) map[int]*model.Emissions { + result := make(map[int]*model.Emissions) + for i, e := range s.emissions { + result[i] = e + } + return result +} + +func (s fakeEmissionsStorage) GetCountry(name string) *model.Country { + return nil +} + +func (s fakeEmissionsStorage) GetCountries() []*model.Country { + return nil +} + +func TestEmissionsListByYear(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := chi.NewRouteContext() + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, ctx)) + emissionsHandler := EmissionResource{Storage: fakeEmissionsStorage{[]*model.Emissions{{ + NOxEmissions: 1, + }, { + NOxEmissions: 2, + }, { + NOxEmissions: 3, + }}}} + + emissionsHandler.ListByYear(w, req) + res := w.Result() + defer res.Body.Close() + _, err := ioutil.ReadAll(res.Body) + assert.Nil(t, err) + assert.Equal(t, 200, res.StatusCode) + + // TODO validate response body +} + +func TestEmissionsListByCountry(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := chi.NewRouteContext() + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, ctx)) + emissionsHandler := EmissionResource{Storage: fakeEmissionsStorage{[]*model.Emissions{{ + NOxEmissions: 1, + }, { + NOxEmissions: 2, + }, { + NOxEmissions: 3, + }}}} + + emissionsHandler.ListByCountry(w, req) + res := w.Result() + defer res.Body.Close() + _, err := ioutil.ReadAll(res.Body) + assert.Nil(t, err) + assert.Equal(t, 200, res.StatusCode) + + // TODO validate response body +} + +func TestEmissionsGetByCountry(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := chi.NewRouteContext() + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, ctx)) + ctx.URLParams.Add("name", "Schlaraffenland") + emissionsHandler := EmissionResource{Storage: fakeEmissionsStorage{[]*model.Emissions{{ + NOxEmissions: 10, + }, { + NOxEmissions: 2, + }, { + NOxEmissions: 3, + }}}} + + emissionsHandler.GetByCountry(w, req) + res := w.Result() + defer res.Body.Close() + data, err := ioutil.ReadAll(res.Body) + assert.Nil(t, err) + assert.Equal(t, 200, res.StatusCode) + + airPollutionEmissions := airPollutionEmissionsResponse{} + err = json.Unmarshal(data, &airPollutionEmissions) + assert.Nil(t, err) + assert.Equal(t, 5.0, airPollutionEmissions.NOxEmissions.Average) + assert.Equal(t, 3.0, airPollutionEmissions.NOxEmissions.Median) + assert.Equal(t, 3.559026084010437, airPollutionEmissions.NOxEmissions.StandardDeviation) } diff --git a/internal/resource/error_test.go b/internal/resource/error_test.go index 9328bd3..36afbe4 100644 --- a/internal/resource/error_test.go +++ b/internal/resource/error_test.go @@ -3,5 +3,5 @@ package resource import "testing" func TestError(t *testing.T) { - // TODO + // TODO add tests } diff --git a/internal/repository/repository.go b/internal/store/storage.go similarity index 73% rename from internal/repository/repository.go rename to internal/store/storage.go index 6f05581..281e937 100644 --- a/internal/repository/repository.go +++ b/internal/store/storage.go @@ -1,4 +1,4 @@ -package repository +package store import ( "air-pollution-service/internal/csv" @@ -6,12 +6,20 @@ import ( "fmt" ) -type Repository struct { +type Store struct { emissions map[string]map[int]model.Emissions countries map[string]model.Country } -func New(file *csv.File) (*Repository, error) { +type Storage interface { + FindAllByYears() map[int][]*model.Emissions + FindAllByCountries() map[string][]*model.Emissions + FindAllByCountry(name string) map[int]*model.Emissions + GetCountry(name string) *model.Country + GetCountries() []*model.Country +} + +func New(file *csv.File) (*Store, error) { rows, err := file.ReadRows() if err != nil { return nil, err @@ -27,7 +35,7 @@ func New(file *csv.File) (*Repository, error) { return nil, err } - return &Repository{ + return &Store{ emissions: emissions, countries: countries, }, nil @@ -74,9 +82,9 @@ func toCountries(rowsFromFile []*csv.Row) (map[string]model.Country, error) { return countries, nil } -func (r *Repository) FindAllByYears() map[int][]*model.Emissions { +func (s *Store) FindAllByYears() map[int][]*model.Emissions { emissions := make(map[int][]*model.Emissions) - for _, countryEmissions := range r.emissions { + for _, countryEmissions := range s.emissions { for year, countryEmissionsOfYear := range countryEmissions { _, found := emissions[year] if !found { @@ -89,9 +97,9 @@ func (r *Repository) FindAllByYears() map[int][]*model.Emissions { return emissions } -func (r *Repository) FindAllByCountries() map[string][]*model.Emissions { +func (s *Store) FindAllByCountries() map[string][]*model.Emissions { emissions := make(map[string][]*model.Emissions) - for name, countryEmissions := range r.emissions { + for name, countryEmissions := range s.emissions { emissions[name] = []*model.Emissions{} for _, countryEmissionsOfYear := range countryEmissions { emissions[name] = append(emissions[name], &countryEmissionsOfYear) @@ -100,25 +108,25 @@ func (r *Repository) FindAllByCountries() map[string][]*model.Emissions { return emissions } -func (r *Repository) FindAllByCountry(name string) map[int]*model.Emissions { +func (s *Store) FindAllByCountry(name string) map[int]*model.Emissions { emissions := make(map[int]*model.Emissions) - for year, countryEmissionsOfYear := range r.emissions[name] { + for year, countryEmissionsOfYear := range s.emissions[name] { emissions[year] = &countryEmissionsOfYear } return emissions } -func (r *Repository) GetCountry(name string) *model.Country { - country, exists := r.countries[name] +func (s *Store) GetCountry(name string) *model.Country { + country, exists := s.countries[name] if exists { return &country } return nil } -func (r *Repository) GetCountries() []*model.Country { +func (s *Store) GetCountries() []*model.Country { var countries []*model.Country - for _, country := range r.countries { + for _, country := range s.countries { countries = append(countries, &country) } return countries diff --git a/internal/repository/repository_test.go b/internal/store/storage_test.go similarity index 63% rename from internal/repository/repository_test.go rename to internal/store/storage_test.go index 15031f0..337d69d 100644 --- a/internal/repository/repository_test.go +++ b/internal/store/storage_test.go @@ -1,7 +1,7 @@ -package repository +package store import "testing" func TestRepository(t *testing.T) { - // TODO + // TODO add tests }