diff --git a/README.md b/README.md index bda724c..f3f80de 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ +# Running Instruction +After clone, run this command: + +```bash +cd api-gateway +docker-compose up +``` + +Application will listen on +```bash +http://localhost:3000 +``` + +Documentation (Swagger) can be accessed : +```bash +http://localhost:3000/swagger/index.html +``` + +There is scheduler container which is hit this endpoint will be trigger every hour to fetch the data and insert it in the PostgreSQL database + +```bash +POST http://localhost:3000/api/v1/indego-data-fetch-and-store-it-db +``` + ## Golang Backend Challenge [Indego](https://www.rideindego.com) is Philadelphia's bike-sharing program, with many bike stations in the city. diff --git a/api-gateway/.env b/api-gateway/.env new file mode 100644 index 0000000..152be9f --- /dev/null +++ b/api-gateway/.env @@ -0,0 +1,8 @@ +DB_HOST=postgresdb +DB_DRIVER=postgres +DB_USER=postgres +DB_PASSWORD=3p1phyte-corp.com +DB_NAME=postgres +DB_PORT=5432 + +OPENWEATHER_APIKEY=598eb70eacf1d53a1eec6ef3e6da25c2 diff --git a/api-gateway/.gitignore b/api-gateway/.gitignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/api-gateway/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/api-gateway/Dockerfile-api b/api-gateway/Dockerfile-api new file mode 100644 index 0000000..0014428 --- /dev/null +++ b/api-gateway/Dockerfile-api @@ -0,0 +1,14 @@ +FROM golang:1.22.5-alpine3.19 AS builder + +WORKDIR /build +COPY . . +RUN go mod download +RUN go get -u github.com/swaggo/swag +RUN go build -o ./api-gateway ./cmd/api-gateway/main.go + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +COPY --from=builder /build/api-gateway ./api-gateway +COPY --from=builder /build/config ./config + +CMD ["./api-gateway"] diff --git a/api-gateway/Dockerfile-cron b/api-gateway/Dockerfile-cron new file mode 100644 index 0000000..c84f597 --- /dev/null +++ b/api-gateway/Dockerfile-cron @@ -0,0 +1,8 @@ +FROM python:3.11-slim-buster + +WORKDIR /build + +RUN pip install requests schedule +COPY cron_job.py . +# RUN chmod +x cron_job.py +CMD ["python", "cron_job.py"] \ No newline at end of file diff --git a/api-gateway/Makefile b/api-gateway/Makefile new file mode 100644 index 0000000..22b48b0 --- /dev/null +++ b/api-gateway/Makefile @@ -0,0 +1,10 @@ +build: + go build -o api-gateway ./cmd/api-gateway + +swag: + swag init -g ./cmd/api-gateway/main.go --markdownFiles swagger-markdown --parseDependency true + +run: + go run cmd/api-gateway/main.go + +.PHONY: swag run diff --git a/api-gateway/api/handlers/handlers.go b/api-gateway/api/handlers/handlers.go new file mode 100644 index 0000000..7827d91 --- /dev/null +++ b/api-gateway/api/handlers/handlers.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "net/http" + "strconv" + "sync" + "time" + + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + "github.com/arthben/BackendGolang/api-gateway/api/middlewares" + "github.com/arthben/BackendGolang/api-gateway/api/openweather" + "github.com/arthben/BackendGolang/api-gateway/api/rideindego" + "github.com/arthben/BackendGolang/api-gateway/docs" + "github.com/arthben/BackendGolang/api-gateway/internal/config" + "github.com/arthben/BackendGolang/api-gateway/internal/database" + "github.com/gin-gonic/gin" +) + +type fetchResult struct { + HTTPCode int + Error error +} + +type Handlers struct { + router *gin.Engine + rideindego *rideindego.Service + openweather *openweather.Service +} + +func Barusaja() { + +} + +func NewHandler(db database.DBService, cfg *config.EnvParams) *Handlers { + + return &Handlers{ + router: gin.New(), + rideindego: rideindego.NewService(db), + openweather: openweather.NewService(db, cfg), + } +} + +func (h *Handlers) BuildHandler() (http.Handler, error) { + h.setupMiddlewares() + h.setupSwagger() + + apiv1 := h.router.Group("/api/v1") + { + apiv1.POST("/indego-data-fetch-and-store-it-db", h.FetchAndStoreIndego) + apiv1.GET("/stations", h.FindSpecifTime) + apiv1.GET("/stations/:kioskId", h.FindKioskWithTime) + } + + return http.Handler(h.router), nil +} + +func (h *Handlers) setupMiddlewares() { + h.router.Use(gin.Recovery()) + h.router.Use(middlewares.AuthMiddleware()) + h.router.Use(middlewares.CorsMiddleware()) +} + +func (h *Handlers) setupSwagger() { + docs.SwaggerInfo.Version = "1.0" + h.router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) +} + +func (h *Handlers) fetchIndego() fetchResult { + httCode, err := h.rideindego.FetchAndStore() + return fetchResult{HTTPCode: httCode, Error: err} +} + +func (h *Handlers) fetchWeather() fetchResult { + httpCode, err := h.openweather.FetchAndStore() + return fetchResult{HTTPCode: httpCode, Error: err} +} + +// FetchAndStoreIndego godoc +// @Summary Store data from Indego +// @Description.markdown data-fetch +// @Tags API +// @Produce json +// @Param Authorization header string true "Bearer secret_token_static" +// @Router /api/v1/indego-data-fetch-and-store-it-db [post] +func (h *Handlers) FetchAndStoreIndego(c *gin.Context) { + var ( + waitGroup sync.WaitGroup + chIndego = make(chan fetchResult) + chWeather = make(chan fetchResult) + ) + + waitGroup.Add(3) + + go func() { + waitGroup.Wait() + close(chIndego) + close(chWeather) + }() + + go func() { + defer waitGroup.Done() + result := h.fetchIndego() + chIndego <- result + }() + + go func() { + defer waitGroup.Done() + result := h.fetchWeather() + chWeather <- result + }() + + _ = <-chWeather + resIndego := <-chIndego + + if resIndego.Error != nil { + c.AbortWithStatusJSON(resIndego.HTTPCode, gin.H{"error": resIndego.Error}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "Fetch and store success"}) +} + +// FindKioskWithTime godoc +// @Summary Snapshot of all stations at a specified time +// @Description.markdown stationsKioskId +// @Tags API +// @Produce json +// @Param Authorization header string true "Bearer secret_token_static" +// @Param at query string true "ex: 2019-09-01T10:00:00Z" +// @Param kioskId path string true "ex: 3005" +// @Router /api/v1/stations/{kioskId} [get] +func (h *Handlers) FindKioskWithTime(c *gin.Context) { + kioskId := c.Param("kioskId") + if _, err := strconv.Atoi(kioskId); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid kioskId format"}) + return + } + + q := c.Query("at") + if len(q) == 0 { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request Parameter"}) + return + } + + at, err := time.Parse(time.RFC3339, q) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid timestamp format"}) + return + } + + jsonIndego, httpCode, err := h.rideindego.Search(at, kioskId) + if err != nil { + c.AbortWithStatusJSON(httpCode, gin.H{"error": err.Error()}) + return + } + + jsonWeather, httpCode, err := h.openweather.Search(at) + if err != nil { + c.AbortWithStatusJSON(httpCode, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "at": jsonIndego.LastUpdated.UTC(), + "stations": jsonIndego, + "weather": jsonWeather, + }) +} + +// FindSpecifTime godoc +// @Summary Snapshot of one station at a specific time +// @Description.markdown stations +// @Tags API +// @Produce json +// @Param Authorization header string true "Bearer secret_token_static" +// @Param at query string true "ex: 2019-09-01T10:00:00Z" +// @Router /api/v1/stations [get] +func (h *Handlers) FindSpecifTime(c *gin.Context) { + q := c.Query("at") + if len(q) == 0 { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request Parameter"}) + return + } + + at, err := time.Parse(time.RFC3339, q) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid timestamp format"}) + return + } + + jsonIndego, httpCode, err := h.rideindego.Search(at, "") + if err != nil { + c.AbortWithStatusJSON(httpCode, gin.H{"error": err.Error()}) + return + } + + jsonWeather, httpCode, err := h.openweather.Search(at) + if err != nil { + c.AbortWithStatusJSON(httpCode, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "at": q, + "stations": jsonIndego, + "weather": jsonWeather, + }) +} diff --git a/api-gateway/api/handlers/handlers_test.go b/api-gateway/api/handlers/handlers_test.go new file mode 100644 index 0000000..bd90864 --- /dev/null +++ b/api-gateway/api/handlers/handlers_test.go @@ -0,0 +1,287 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + + "github.com/arthben/BackendGolang/api-gateway/internal/config" + "github.com/arthben/BackendGolang/api-gateway/internal/database" + "github.com/gin-gonic/gin" +) + +type ResponseBody struct { + At string `json:"at"` +} + +var ( + cfg *config.EnvParams + dbPool database.DBService +) + +func init() { + os.Chdir("../..") + conf, err := config.LoadConfig() + if err != nil { + fmt.Printf("%v\n", err) + panic(0) + } + cfg = conf + + repo, err := database.NewPool(cfg) + if err != nil { + fmt.Printf("%v\n", err) + panic(0) + } + + dbPool = repo +} + +func setupRouter() *gin.Engine { + h := NewHandler(dbPool, cfg) + h.BuildHandler() + return h.router +} + +func TestStationsWithKioskId(t *testing.T) { + ro := setupRouter() + + scenarios := []struct { + name string + header map[string]string + paramAt string + paramKioskId string + expectedStatus int + }{ + { + name: "Success", + header: map[string]string{ + "Authorization": "Bearer secret_token_static", + }, + paramAt: "2024-11-08T01:00:00Z", + paramKioskId: "3005", + expectedStatus: http.StatusOK, + }, + { + name: "Fail - KioskId not valid", + header: map[string]string{ + "Authorization": "Bearer secret_token_static", + }, + paramAt: "2024-11-08T01:00:00Z", + paramKioskId: "00000", + expectedStatus: http.StatusNotFound, + }, + { + name: "Fail - Without Token", + header: map[string]string{}, + paramAt: "2024-11-08T01:00:00Z", + paramKioskId: "3005", + expectedStatus: http.StatusUnauthorized, + }, + { + name: "Fail - Invalid Token", + header: map[string]string{ + "Authorization": "Bearer should_be_secret_token_static", + }, + paramAt: "2024-11-08T01:00:00Z", + paramKioskId: "3005", + expectedStatus: http.StatusUnauthorized, + }, + { + name: "Fail - No Data Found", + header: map[string]string{ + "Authorization": "Bearer secret_token_static", + }, + paramAt: "2024-12-08T01:00:00Z", + paramKioskId: "3005", + expectedStatus: http.StatusNotFound, + }, + } + + for _, ts := range scenarios { + t.Run(ts.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/api/v1/stations/"+ts.paramKioskId+"?at="+ts.paramAt, nil) + if err != nil { + t.Errorf("Expected status %d but error occured - %v", ts.expectedStatus, err.Error()) + return + } + + for key, v := range ts.header { + req.Header.Add(key, v) + } + + w := httptest.NewRecorder() + ro.ServeHTTP(w, req) + + if w.Code != ts.expectedStatus { + t.Errorf("Expected status %d but got response %d", ts.expectedStatus, w.Code) + return + } + + if ts.expectedStatus == http.StatusOK { + var res ResponseBody + err = json.Unmarshal(w.Body.Bytes(), &res) + if err != nil { + t.Errorf("Expected status %d but error occured - %v", ts.expectedStatus, err.Error()) + return + } + + expectedResponseBody := ResponseBody{At: ts.paramAt} + if !reflect.DeepEqual(res, expectedResponseBody) { + t.Errorf("Expected status %d but response body mismatch - %v", ts.expectedStatus, res.At) + return + } + } + }) + } +} + +func TestStations(t *testing.T) { + ro := setupRouter() + + scenarios := []struct { + name string + header map[string]string + paramAt string + expectedStatus int + }{ + { + name: "Success", + header: map[string]string{ + "Authorization": "Bearer secret_token_static", + }, + paramAt: "2024-11-08T01:00:00Z", + expectedStatus: http.StatusOK, + }, + { + name: "Fail - Without Token", + header: map[string]string{}, + paramAt: "2024-11-08T01:00:00Z", + expectedStatus: http.StatusUnauthorized, + }, + { + name: "Fail - Invalid Token", + header: map[string]string{ + "Authorization": "Bearer should_be_secret_token_static", + }, + paramAt: "2024-11-08T01:00:00Z", + expectedStatus: http.StatusUnauthorized, + }, + { + name: "Fail - No Data Found", + header: map[string]string{ + "Authorization": "Bearer secret_token_static", + }, + paramAt: "2024-12-08T01:00:00Z", + expectedStatus: http.StatusNotFound, + }, + } + + for _, ts := range scenarios { + t.Run(ts.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/api/v1/stations?at="+ts.paramAt, nil) + if err != nil { + t.Errorf("Expected status %d but error occured - %v", ts.expectedStatus, err.Error()) + return + } + + for key, v := range ts.header { + req.Header.Add(key, v) + } + + w := httptest.NewRecorder() + ro.ServeHTTP(w, req) + + if w.Code != ts.expectedStatus { + t.Errorf("Expected status %d but got response %d", ts.expectedStatus, w.Code) + return + } + + if ts.expectedStatus == http.StatusOK { + var res ResponseBody + err = json.Unmarshal(w.Body.Bytes(), &res) + if err != nil { + t.Errorf("Expected status %d but error occured - %v", ts.expectedStatus, err.Error()) + return + } + + expectedResponseBody := ResponseBody{At: ts.paramAt} + if !reflect.DeepEqual(res, expectedResponseBody) { + t.Errorf("Expected status %d but response body mismatch - %v", ts.expectedStatus, res.At) + return + } + } + }) + } +} + +func TestFetchAndStore(t *testing.T) { + ro := setupRouter() + + scenarios := []struct { + name string + header map[string]string + expectedStatus int + }{ + { + name: "Success", + header: map[string]string{ + "Authorization": "Bearer secret_token_static", + }, + expectedStatus: http.StatusOK, + }, + { + name: "Fail - Without Token", + header: map[string]string{}, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "Fail - Invalid Token", + header: map[string]string{ + "Authorization": "Bearer should_be_secret_token_static", + }, + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, ts := range scenarios { + t.Run(ts.name, func(t *testing.T) { + req, err := http.NewRequest("POST", "/api/v1/indego-data-fetch-and-store-it-db", nil) + if err != nil { + t.Errorf("Expected status %d but error occured - %v", ts.expectedStatus, err.Error()) + return + } + + for key, v := range ts.header { + req.Header.Add(key, v) + } + + w := httptest.NewRecorder() + ro.ServeHTTP(w, req) + + if w.Code != ts.expectedStatus { + t.Errorf("Expected status %d but got response %d", ts.expectedStatus, w.Code) + return + } + + if ts.expectedStatus == http.StatusOK { + var res map[string]string + err = json.Unmarshal(w.Body.Bytes(), &res) + if err != nil { + t.Errorf("Expected status %d but error occured - %v", ts.expectedStatus, err.Error()) + return + } + + if res["status"] != "Fetch and store success" { + t.Errorf("Expected status %d but response body mismatch - %v", ts.expectedStatus, res["status"]) + return + } + + } + }) + } +} diff --git a/api-gateway/api/middlewares/middlewares.go b/api-gateway/api/middlewares/middlewares.go new file mode 100644 index 0000000..7283579 --- /dev/null +++ b/api-gateway/api/middlewares/middlewares.go @@ -0,0 +1,40 @@ +package middlewares + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/swagger/") { + c.Next() + return + } + + authorizationHeader := c.GetHeader("Authorization") + if authorizationHeader == "Bearer secret_token_static" { + c.Next() + } else { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unathorized Access"}) + } + } +} + +func CorsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-www-form-urlencoded") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/api-gateway/api/openweather/entity.go b/api-gateway/api/openweather/entity.go new file mode 100644 index 0000000..e0f5c32 --- /dev/null +++ b/api-gateway/api/openweather/entity.go @@ -0,0 +1,62 @@ +package openweather + +type Clouds struct { + All int `json:"all"` +} + +type Coord struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +type Rain struct { + OneH float64 `json:"1h"` +} + +type Sys struct { + Country string `json:"country"` + ID int `json:"id"` + Sunrise int `json:"sunrise"` + Sunset int `json:"sunset"` + Type int `json:"type"` +} + +type Main struct { + FeelsLike float64 `json:"feels_like"` + GrndLevel int `json:"grnd_level"` + Humidity int `json:"humidity"` + Pressure int `json:"pressure"` + SeaLevel int `json:"sea_level"` + Temp float64 `json:"temp"` + TempMax float64 `json:"temp_max"` + TempMin float64 `json:"temp_min"` +} + +type Weather struct { + Description string `json:"description"` + Icon string `json:"icon"` + ID int `json:"id"` + Main string `json:"main"` +} + +type Wind struct { + Deg int `json:"deg"` + Speed float64 `json:"speed"` +} + +type FetchResponse struct { + Base string `json:"base"` + Clouds Clouds `json:"clouds"` + Cod int `json:"cod"` + Coord Coord `json:"coord"` + Dt int `json:"dt"` + ID int `json:"id"` + Main Main `json:"main"` + Name string `json:"name"` + Rain Rain `json:"rain"` + Sys Sys `json:"sys"` + Timezone int `json:"timezone"` + Visibility int `json:"visibility"` + Weather []Weather `json:"weather"` + Wind Wind `json:"wind"` +} diff --git a/api-gateway/api/openweather/openweather.go b/api-gateway/api/openweather/openweather.go new file mode 100644 index 0000000..96fc3d7 --- /dev/null +++ b/api-gateway/api/openweather/openweather.go @@ -0,0 +1,178 @@ +package openweather + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/arthben/BackendGolang/api-gateway/internal/client" + "github.com/arthben/BackendGolang/api-gateway/internal/config" + dbase "github.com/arthben/BackendGolang/api-gateway/internal/database" + "github.com/google/uuid" +) + +const ( + Timeout = 10 * time.Second + BaseURL = "https://api.openweathermap.org/data/2.5/weather" +) + +type Service struct { + db dbase.DBService + cfg *config.EnvParams +} + +func NewService(db dbase.DBService, cfg *config.EnvParams) *Service { + return &Service{db: db, cfg: cfg} +} + +func (o *Service) FetchAndStore() (int, error) { + params := url.Values{} + params.Add("q", "Philadelphia") + params.Add("appid", o.cfg.OpenWeather.APIKey) + + url := fmt.Sprintf("%s?%s", BaseURL, params.Encode()) + rawData, httpStatus, err := client.SendHTTPRequest(http.MethodGet, Timeout, nil, url) + if err != nil { + return httpStatus, err + } + + // get json data + var jsonData FetchResponse + err = client.GetJSON(rawData, &jsonData) + if err != nil { + return http.StatusInternalServerError, errors.New("Error reading response body") + } + + // save to database + err = o.storeToDB(&jsonData) + if err != nil { + return http.StatusInternalServerError, errors.New("Error while store Openweather data") + } + + return http.StatusOK, nil +} + +func (o *Service) storeToDB(fetchResponse *FetchResponse) error { + fetchID := uuid.NewString() + + // save master + master := &dbase.OpenWeatherMaster{ + FetchID: fetchID, + Base: fetchResponse.Base, + Clouds: fetchResponse.Clouds.All, + COD: fetchResponse.Cod, + Coord: fmt.Sprintf("POINT(%f %f)", fetchResponse.Coord.Lat, fetchResponse.Coord.Lon), + DT: fetchResponse.Dt, + ID: fetchResponse.ID, + MainFeelsLike: fetchResponse.Main.FeelsLike, + MainGrndLevel: fetchResponse.Main.GrndLevel, + MainHumidity: fetchResponse.Main.Humidity, + MainPressure: fetchResponse.Main.Pressure, + MainSeaLevel: fetchResponse.Main.SeaLevel, + MainTemp: fetchResponse.Main.Temp, + MainTempMax: fetchResponse.Main.TempMax, + MainTempMin: fetchResponse.Main.TempMin, + Name: fetchResponse.Name, + RainOneHour: fetchResponse.Rain.OneH, + SysCountry: fetchResponse.Sys.Country, + SysID: fetchResponse.Sys.ID, + SysSunrise: fetchResponse.Sys.Sunrise, + SysSunset: fetchResponse.Sys.Sunset, + SysType: fetchResponse.Sys.Type, + Timezone: fetchResponse.Timezone, + Visibility: fetchResponse.Visibility, + WindDeg: fetchResponse.Wind.Deg, + WindSpeed: fetchResponse.Wind.Speed, + } + + var details []*dbase.OpenWewatherDetail + + for i, detail := range fetchResponse.Weather { + details = append(details, &dbase.OpenWewatherDetail{ + FetchID: fetchID, + Index: i, + Description: detail.Description, + Icon: detail.Icon, + ID: detail.ID, + Main: detail.Main, + }) + } + + err := o.db.StoreOpenWeather(context.Background(), master, details) + return err +} + +func (o *Service) Search(at time.Time) (*FetchResponse, int, error) { + tbl, err := o.db.SearchOpenWeather(context.Background(), at) + if err != nil { + return nil, http.StatusNotFound, err + } + + // compose return value + weathers := make([]Weather, 0) + for _, weather := range tbl.Detail { + weathers = append(weathers, Weather{ + Description: weather.Description, + Icon: weather.Icon, + ID: weather.ID, + Main: weather.Main, + }) + } + + resp := FetchResponse{ + Base: tbl.Master.Base, + Clouds: Clouds{ + All: tbl.Master.Clouds, + }, + Cod: tbl.Master.COD, + Coord: unmarshalCoordinate(tbl.Master.Coord), + Dt: tbl.Master.DT, + ID: tbl.Master.ID, + Main: Main{ + FeelsLike: tbl.Master.MainFeelsLike, + GrndLevel: tbl.Master.MainGrndLevel, + Humidity: tbl.Master.MainHumidity, + Pressure: tbl.Master.MainPressure, + SeaLevel: tbl.Master.MainSeaLevel, + Temp: tbl.Master.MainTemp, + TempMax: tbl.Master.MainTempMax, + TempMin: tbl.Master.MainTempMin, + }, + Name: tbl.Master.Name, + Rain: Rain{ + OneH: tbl.Master.RainOneHour, + }, + Sys: Sys{ + Country: tbl.Master.SysCountry, + ID: tbl.Master.SysID, + Sunrise: tbl.Master.SysSunrise, + Sunset: tbl.Master.SysSunset, + Type: tbl.Master.SysType, + }, + Timezone: tbl.Master.Timezone, + Visibility: tbl.Master.Visibility, + Weather: weathers, + Wind: Wind{ + Deg: tbl.Master.WindDeg, + Speed: tbl.Master.WindSpeed, + }, + } + return &resp, http.StatusOK, nil +} + +func unmarshalCoordinate(coordinate string) Coord { + var lon, lat float64 + + _, err := fmt.Sscanf(coordinate, "POINT(%f %f)", &lon, &lat) + if err != nil { + return Coord{} + } + + return Coord{ + Lat: lat, + Lon: lon, + } +} diff --git a/api-gateway/api/openweather/openweather_test.go b/api-gateway/api/openweather/openweather_test.go new file mode 100644 index 0000000..db919d1 --- /dev/null +++ b/api-gateway/api/openweather/openweather_test.go @@ -0,0 +1,64 @@ +package openweather + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/arthben/BackendGolang/api-gateway/internal/config" + "github.com/arthben/BackendGolang/api-gateway/internal/database" +) + +var ( + db database.DBService + cfg *config.EnvParams +) + +func TestSearch(t *testing.T) { + service := NewService(db, cfg) + at := time.Date(2024, 11, 21, 0, 0, 0, 0, time.UTC) + resp, _, err := service.Search(at) + if err != nil { + fmt.Printf("err: %v\n", err) + return + } + + fmt.Printf("resp: %v\n", resp) +} + +func TestFetch(t *testing.T) { + t.Run("test function FetchData", func(t *testing.T) { + service := NewService(db, cfg) + httpCode, err := service.FetchAndStore() + if err != nil { + t.Errorf("Expected no error, but error occur %s\n", err) + return + } + + if httpCode != http.StatusOK { + t.Errorf("Expected status %d but got response %d", http.StatusOK, httpCode) + return + } + }) +} + +func init() { + os.Chdir("../..") + + conf, err := config.LoadConfig() + if err != nil { + fmt.Printf("%v\n", err) + panic(0) + } + cfg = conf + + repo, err := database.NewPool(cfg) + if err != nil { + fmt.Printf("%v\n", err) + panic(0) + } + + db = repo +} diff --git a/api-gateway/api/rideindego/entity.go b/api-gateway/api/rideindego/entity.go new file mode 100644 index 0000000..88eb798 --- /dev/null +++ b/api-gateway/api/rideindego/entity.go @@ -0,0 +1,65 @@ +package rideindego + +import ( + "time" +) + +type Bikes struct { + Battery int `json:"battery"` + DockNumber int `json:"dockNumber"` + IsElectric bool `json:"isElectric"` + IsAvailable bool `json:"isAvailable"` +} + +type Properties struct { + ID int `json:"id"` + Name string `json:"name"` + Bikes []Bikes `json:"bikes"` + Notes string `json:"notes"` + KioskID int `json:"kioskId"` + EventEnd string `json:"eventEnd"` + Latitude float64 `json:"latitude"` + OpenTime string `json:"openTime"` + TimeZone string `json:"timeZone"` + CloseTime string `json:"closeTime"` + IsVirtual bool `json:"isVirtual"` + KioskType int `json:"kioskType"` + Longitude float64 `json:"longitude"` + EventStart string `json:"eventStart"` + PublicText string `json:"publicText"` + TotalDocks int `json:"totalDocks"` + AddressCity string `json:"addressCity"` + Coordinates []float64 `json:"coordinates"` + KioskStatus string `json:"kioskStatus"` + AddressState string `json:"addressState"` + IsEventBased bool `json:"isEventBased"` + AddressStreet string `json:"addressStreet"` + AddressZipCode string `json:"addressZipCode"` + BikesAvailable int `json:"bikesAvailable"` + DocksAvailable int `json:"docksAvailable"` + TrikesAvailable int `json:"trikesAvailable"` + KioskPublicStatus string `json:"kioskPublicStatus"` + SmartBikesAvailable int `json:"smartBikesAvailable"` + RewardBikesAvailable int `json:"rewardBikesAvailable"` + RewardDocksAvailable int `json:"rewardDocksAvailable"` + ClassicBikesAvailable int `json:"classicBikesAvailable"` + KioskConnectionStatus string `json:"kioskConnectionStatus"` + ElectricBikesAvailable int `json:"electricBikesAvailable"` +} + +type Geometry struct { + Type string `json:"type"` + Coordinates []float64 `json:"coordinates"` +} + +type Features struct { + Type string `json:"type"` + Geometry Geometry `json:"geometry"` + Properties Properties `json:"properties"` +} + +type FetchResponse struct { + Type string `json:"type"` + Features []Features `json:"features"` + LastUpdated time.Time `json:"last_updated"` +} diff --git a/api-gateway/api/rideindego/rideindego.go b/api-gateway/api/rideindego/rideindego.go new file mode 100644 index 0000000..3464514 --- /dev/null +++ b/api-gateway/api/rideindego/rideindego.go @@ -0,0 +1,234 @@ +package rideindego + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/arthben/BackendGolang/api-gateway/internal/client" + dbase "github.com/arthben/BackendGolang/api-gateway/internal/database" + "github.com/google/uuid" +) + +const ( + Timeout = 10 * time.Second + LastUpdateLayout = "2006-01-02T15:04:05.999Z" + BaseURL = "https://www.rideindego.com/stations/json/" +) + +type Service struct { + db dbase.DBService +} + +func NewService(db dbase.DBService) *Service { + return &Service{db: db} +} + +func (r *Service) Search(at time.Time, kioskId string) (*FetchResponse, int, error) { + tbl, err := r.db.SearchRideIndego(context.Background(), at, kioskId) + if err != nil { + return nil, http.StatusNotFound, err + } + + // compose return value + features := []Features{} + for _, feature := range tbl.Features { + fID := feature.FeatureID + properties := parseProperties(tbl.Properties[fID], tbl.PropertiesBikes[fID]) + + features = append(features, Features{ + Type: feature.FeatureType, + Geometry: Geometry{ + Type: feature.GeometryType, + Coordinates: unmarshalCoordinate(feature.GeometryCoordinate), + }, + Properties: properties, + }) + } + + resp := FetchResponse{ + Type: tbl.Master.TypeCollection, + Features: features, + LastUpdated: tbl.Master.LastUpdate.UTC(), + } + + return &resp, http.StatusOK, nil +} + +func (r *Service) FetchAndStore() (int, error) { + + // fetch data + rawData, httpStatus, err := client.SendHTTPRequest(http.MethodGet, Timeout, nil, BaseURL) + if err != nil { + return httpStatus, err + } + + // get json data + var jsonData FetchResponse + err = client.GetJSON(rawData, &jsonData) + if err != nil { + return http.StatusInternalServerError, errors.New("Error reading response body") + } + + // save to database + err = r.storeToDB(&jsonData) + if err != nil { + return http.StatusInternalServerError, errors.New("Error while store RideIndego data") + } + return http.StatusOK, nil +} + +func (r *Service) storeToDB(fetchResponse *FetchResponse) error { + fetchID := uuid.NewString() + featureID := int(fetchResponse.LastUpdated.Unix()) + + // save master + master := &dbase.RideIndegoMaster{ + FetchID: fetchID, + TypeCollection: fetchResponse.Type, + LastUpdate: fetchResponse.LastUpdated, + } + + var ( + features []*dbase.RideIndegoFeatures + properties []*dbase.RideIndegoProperties + propBikes []*dbase.RideIndegoBikes + ) + + for i, val := range fetchResponse.Features { + featureID += i + + // compose feature table + features = append(features, &dbase.RideIndegoFeatures{ + FetchID: fetchID, + FeatureID: featureID, + FeatureType: val.Type, + GeometryType: val.Geometry.Type, + GeometryCoordinate: fmt.Sprintf("POINT(%f %f)", val.Geometry.Coordinates[0], val.Geometry.Coordinates[1]), + }) + + properties = append(properties, &dbase.RideIndegoProperties{ + FetchID: fetchID, + FeatureID: featureID, + PropertiesID: val.Properties.ID, + Name: val.Properties.Name, + Notes: val.Properties.Notes, + KioskID: val.Properties.KioskID, + EventEnd: val.Properties.EventEnd, + Latitude: val.Properties.Latitude, + OpenTime: val.Properties.OpenTime, + TimeZone: val.Properties.TimeZone, + CloseTime: val.Properties.CloseTime, + IsVirtual: val.Properties.IsVirtual, + KioskType: val.Properties.KioskType, + Longitude: val.Properties.Longitude, + EventStart: val.Properties.EventStart, + PublicText: val.Properties.PublicText, + TotalDocks: val.Properties.TotalDocks, + AddressCity: val.Properties.AddressCity, + Coordinates: fmt.Sprintf("POINT(%f %f)", val.Properties.Coordinates[0], val.Properties.Coordinates[1]), + KioskStatus: val.Properties.KioskStatus, + AddressState: val.Properties.AddressState, + IsEventBased: val.Properties.IsEventBased, + AddressStreet: val.Properties.AddressStreet, + AddressZipCode: val.Properties.AddressZipCode, + BikesAvailable: val.Properties.BikesAvailable, + DocksAvailable: val.Properties.DocksAvailable, + TrikesAvailable: val.Properties.TrikesAvailable, + KioskPublicStatus: val.Properties.KioskPublicStatus, + SmartBikesAvailable: val.Properties.SmartBikesAvailable, + RewardBikesAvailable: val.Properties.RewardBikesAvailable, + RewardDocksAvailable: val.Properties.RewardDocksAvailable, + ClassicBikesAvailable: val.Properties.ClassicBikesAvailable, + KioskConnectionStatus: val.Properties.KioskConnectionStatus, + ElectricBikesAvailable: val.Properties.ElectricBikesAvailable, + }) + + for j, bike := range val.Properties.Bikes { + propBikes = append(propBikes, &dbase.RideIndegoBikes{ + FetchID: fetchID, + FeatureID: featureID, + PropertiesID: val.Properties.ID, + Index: j, + Battery: bike.Battery, + DockNumber: bike.DockNumber, + IsElectric: bike.IsElectric, + IsAvailable: bike.IsAvailable, + }) + } + } + + paramStoreData := dbase.ParamStoreRideIndego{ + Master: master, + Features: features, + Properties: properties, + PropertiesBikes: propBikes, + } + return r.db.StoreRideIndego(context.Background(), paramStoreData) +} + +func parseProperties(prop *dbase.RideIndegoProperties, bikes []*dbase.RideIndegoBikes) Properties { + + // keep track each properties have valid Bikes array like fetch result + // table Bikes have foreign reference from Properties table + // key name is featureID. this key used on hashmap to avoid + // query to database for each featureID + arrBikes := make([]Bikes, 0) + for _, bike := range bikes { + arrBikes = append(arrBikes, Bikes{ + Battery: bike.Battery, + DockNumber: bike.DockNumber, + IsElectric: bike.IsElectric, + IsAvailable: bike.IsAvailable, + }) + } + + return Properties{ + ID: prop.PropertiesID, + Name: prop.Name, + Bikes: arrBikes, + Notes: prop.Notes, + KioskID: prop.KioskID, + EventEnd: prop.EventEnd, + Latitude: prop.Latitude, + OpenTime: prop.OpenTime, + TimeZone: prop.TimeZone, + CloseTime: prop.CloseTime, + IsVirtual: prop.IsVirtual, + KioskType: prop.KioskType, + Longitude: prop.Longitude, + EventStart: prop.EventStart, + PublicText: prop.PublicText, + TotalDocks: prop.TotalDocks, + AddressCity: prop.AddressCity, + Coordinates: unmarshalCoordinate(prop.Coordinates), + KioskStatus: prop.KioskStatus, + AddressState: prop.AddressState, + IsEventBased: prop.IsEventBased, + AddressStreet: prop.AddressStreet, + AddressZipCode: prop.AddressZipCode, + BikesAvailable: prop.BikesAvailable, + DocksAvailable: prop.DocksAvailable, + TrikesAvailable: prop.TrikesAvailable, + KioskPublicStatus: prop.KioskPublicStatus, + SmartBikesAvailable: prop.SmartBikesAvailable, + RewardBikesAvailable: prop.RewardBikesAvailable, + RewardDocksAvailable: prop.RewardDocksAvailable, + ClassicBikesAvailable: prop.ClassicBikesAvailable, + KioskConnectionStatus: prop.KioskConnectionStatus, + ElectricBikesAvailable: prop.ElectricBikesAvailable, + } +} + +func unmarshalCoordinate(coordinate string) []float64 { + var lon, lat float64 + + _, err := fmt.Sscanf(coordinate, "POINT(%f %f)", &lon, &lat) + if err != nil { + return []float64{} + } + + return []float64{lon, lat} +} diff --git a/api-gateway/api/rideindego/rideindego_test.go b/api-gateway/api/rideindego/rideindego_test.go new file mode 100644 index 0000000..e30028c --- /dev/null +++ b/api-gateway/api/rideindego/rideindego_test.go @@ -0,0 +1,62 @@ +package rideindego + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/arthben/BackendGolang/api-gateway/internal/config" + "github.com/arthben/BackendGolang/api-gateway/internal/database" +) + +var ( + db database.DBService +) + +func TestSearch(t *testing.T) { + service := NewService(db) + at := time.Date(2024, 11, 21, 0, 0, 0, 0, time.UTC) + resp, _, err := service.Search(at, "3009") + if err != nil { + fmt.Printf("err: %v\n", err) + return + } + + fmt.Printf("resp: %v\n", resp) +} + +func TestFetch(t *testing.T) { + t.Run("test function FetchData", func(t *testing.T) { + service := NewService(db) + httpCode, err := service.FetchAndStore() + if err != nil { + t.Errorf("Expected no error, but error occur %s\n", err) + return + } + + if httpCode != http.StatusOK { + t.Errorf("Expected status %d but got response %d", http.StatusOK, httpCode) + return + } + }) +} + +func init() { + os.Chdir("../..") + + cfg, err := config.LoadConfig() + if err != nil { + fmt.Printf("%v\n", err) + panic(0) + } + + repo, err := database.NewPool(cfg) + if err != nil { + fmt.Printf("%v\n", err) + panic(0) + } + + db = repo +} diff --git a/api-gateway/cmd/api-gateway/main.go b/api-gateway/cmd/api-gateway/main.go new file mode 100644 index 0000000..d6fb342 --- /dev/null +++ b/api-gateway/cmd/api-gateway/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/arthben/BackendGolang/api-gateway/api/handlers" + "github.com/arthben/BackendGolang/api-gateway/internal/config" + "github.com/arthben/BackendGolang/api-gateway/internal/database" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "gopkg.in/natefinch/lumberjack.v2" +) + +// @title Indego & Open Weather API Documentation +// @version 1.0 +// @BasePath / + +func main() { + cfg, err := config.LoadConfig() + if err != nil { + fmt.Printf("%s\n", err) + return + } + + initLogger() + + // init database + dbPool, err := database.NewPool(cfg) + if err != nil { + log.Error().Err(err).Msg("") + return + } + + defer dbPool.Close() + + // init request handler + handler, err := handlers.NewHandler(dbPool, cfg).BuildHandler() + if err != nil { + log.Error().Err(err).Msg("") + return + } + + // timeout := cfg.App.WaitTimeOut + server := http.Server{ + Addr: "0.0.0.0:" + cfg.App.Port, + Handler: handler, + ReadTimeout: time.Duration(cfg.App.WaitTimeOut) * time.Second, + WriteTimeout: time.Duration(cfg.App.WaitTimeOut) * time.Second, + } + defer server.Close() + + osSignal := make(chan os.Signal, 2) + signal.Notify(osSignal, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Info().Str("SERVER START", "address : 0.0.0.0:"+cfg.App.Port) + log.Warn().Str("SERVER CLOSE", "error : "+server.ListenAndServe().Error()) + }() + + // wait until server closed + select { + case <-osSignal: + log.Warn().Msg("Server Closed Interrupted by OS") + } + + log.Info().Msg("SERVER STOP") +} + +func initLogger() { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + zerolog.TimeFieldFormat = time.RFC3339 + log.Logger = zerolog.New(&lumberjack.Logger{ + Filename: "log/api_gateway.log", + MaxSize: 100, + MaxBackups: 3, + MaxAge: 30, + Compress: true, + }) + log.Logger = log.With().Str("service", "api-gateway").Logger() + log.Logger = log.With().Timestamp().Logger() +} diff --git a/api-gateway/config/config.yaml b/api-gateway/config/config.yaml new file mode 100644 index 0000000..2611bb5 --- /dev/null +++ b/api-gateway/config/config.yaml @@ -0,0 +1,11 @@ +app: + port: ${SERVER_PORT} + waitTimeout: ${SERVER_TIMEOUT} + +db: + dsn: ${DB_DSN} + minPool: ${DB_MIN_POOL} + maxPool: ${DB_MAX_POOL} + +openweather: + apikey: ${OPENWEATHER_APIKEY} diff --git a/api-gateway/cron_job.py b/api-gateway/cron_job.py new file mode 100644 index 0000000..ce638d3 --- /dev/null +++ b/api-gateway/cron_job.py @@ -0,0 +1,23 @@ +import time +import requests +import schedule + + +def job(): + url = "http://api_gateway:3000/api/v1/indego-data-fetch-and-store-it-db" + payload = {} + headers = { + 'Authorization': 'Bearer secret_token_static' + } + + response = requests.request("POST", url, headers=headers, data=payload) + + print(response.text) + + +schedule.every().hour.do(job) +# schedule.every(2).minutes.do(job) + +while True: + schedule.run_pending() + time.sleep(1) diff --git a/api-gateway/docker-compose.yaml b/api-gateway/docker-compose.yaml new file mode 100644 index 0000000..abff0d1 --- /dev/null +++ b/api-gateway/docker-compose.yaml @@ -0,0 +1,48 @@ +version: '3' +services: + postgresdb: + image: bitnami/postgresql:latest + environment: + - POSTGRESQL_PASSWORD=${DB_PASSWORD} + - POSTGRESQL_POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRESQL_USERNAME=${DB_USER} + - POSTGRESQL_DATABASE=${DB_NAME} + volumes: + - ./scripts/pg_data:/bitnami/postgresql + - ./scripts/dbInit:/docker-entrypoint-initdb.d/:ro + ports: + - '5432:5432' + networks: + - EpyphiteNet + + api_gateway: + build: + context: . + dockerfile: Dockerfile-api + restart: always + environment: + - SERVER_PORT=3000 + - SERVER_TIMEOUT=60 + - DB_DSN=${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable&timezone=UTC + - DB_MIN_POOL=1 + - DB_MAX_POOL=10 + - OPENWEATHER_APIKEY=${OPENWEATHER_APIKEY} + ports: + - 3000:3000 + networks: + - EpyphiteNet + depends_on: + - postgresdb + + cronjob: + build: + context: . + dockerfile: Dockerfile-cron + restart: always + networks: + - EpyphiteNet + depends_on: + - api_gateway + +networks: + EpyphiteNet: diff --git a/api-gateway/docs/docs.go b/api-gateway/docs/docs.go new file mode 100644 index 0000000..ccb6cdc --- /dev/null +++ b/api-gateway/docs/docs.go @@ -0,0 +1,124 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/indego-data-fetch-and-store-it-db": { + "post": { + "description": "## Store data from Indego\n\nAn endpoints which downloads fresh data from [Indego GeoJSON station status API](https://www.rideindego.com/stations/json/) and stores it inside PostgreSQL.\n\n` + "`" + `` + "`" + `` + "`" + `bash\n# this endpoint will be trigger every hour to fetch the data and insert it in the PostgreSQL database\nPOST http://localhost:3000/api/v1/indego-data-fetch-and-store-it-db\n` + "`" + `` + "`" + `` + "`" + `\n\n### Token \nAdd HTTP header with Authorization \n` + "`" + `` + "`" + `` + "`" + `go\n\n // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n` + "`" + `` + "`" + `` + "`" + `\n\n### Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. Check token |\n", + "produces": [ + "application/json" + ], + "tags": [ + "API" + ], + "summary": "Store data from Indego", + "parameters": [ + { + "type": "string", + "description": "Bearer secret_token_static", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": {} + } + }, + "/api/v1/stations": { + "get": { + "description": "## Snapshot of all stations at a specified time\n\nData for all stations as of 11am Universal Coordinated Time on September 1st, 2019:\n\n` + "`" + `` + "`" + `` + "`" + `bash\nGET http://localhost:3000/api/v1/stations?at=2019-09-01T10:00:00Z\n` + "`" + `` + "`" + `` + "`" + `\n\nThis endpoint should respond as follows, with the actual time of the first snapshot of data on or after the requested time and the data:\n\n` + "`" + `` + "`" + `` + "`" + `javascript\n{\n at: '2019-09-01T10:00:00Z',\n stations: { /* As per the Indego API */ },\n weather: { /* As per the Open Weather Map API response for Philadelphia */ }\n}\n` + "`" + `` + "`" + `` + "`" + `\n\n### Token \nAdd HTTP header with Authorization \n` + "`" + `` + "`" + `` + "`" + `go\n\n // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n` + "`" + `` + "`" + `` + "`" + `\n\n### Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. Check token |\n| 404 | Data Not Found |\n", + "produces": [ + "application/json" + ], + "tags": [ + "API" + ], + "summary": "Snapshot of one station at a specific time", + "parameters": [ + { + "type": "string", + "description": "Bearer secret_token_static", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "ex: 2019-09-01T10:00:00Z", + "name": "at", + "in": "query", + "required": true + } + ], + "responses": {} + } + }, + "/api/v1/stations/{kioskId}": { + "get": { + "description": "## Snapshot of one station at a specific time\n\nData for a specific station (by its ` + "`" + `kioskId` + "`" + `) at a specific time:\n\n` + "`" + `` + "`" + `` + "`" + `bash\nGET http://localhost:3000/api/v1/stations/{kioskId}?at=2019-09-01T10:00:00Z\n` + "`" + `` + "`" + `` + "`" + `\n\nThe response should be the first available on or after the given time, and should look like:\n\n` + "`" + `` + "`" + `` + "`" + `javascript\n{\n at: '2019-09-01T10:00:00',\n station: { /* Data just for this one station as per the Indego API */ },\n weather: { /* As per the Open Weather Map API response for Philadelphia */ }\n}\n` + "`" + `` + "`" + `` + "`" + `\n\nInclude an ` + "`" + `at` + "`" + ` property in the same format indicating the actual time of the snapshot.\n\nIf no suitable data is available a 404 status code should be given.\n\n\n### Token \nAdd HTTP header with Authorization \n` + "`" + `` + "`" + `` + "`" + `go\n\n // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n` + "`" + `` + "`" + `` + "`" + `\n\n### Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. Check token |\n| 404 | Data Not Found |\n", + "produces": [ + "application/json" + ], + "tags": [ + "API" + ], + "summary": "Snapshot of all stations at a specified time", + "parameters": [ + { + "type": "string", + "description": "Bearer secret_token_static", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "ex: 2019-09-01T10:00:00Z", + "name": "at", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "ex: 3005", + "name": "kioskId", + "in": "path", + "required": true + } + ], + "responses": {} + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "Indego & Open Weather API Documentation", + Description: "", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/api-gateway/docs/swagger.json b/api-gateway/docs/swagger.json new file mode 100644 index 0000000..1152b55 --- /dev/null +++ b/api-gateway/docs/swagger.json @@ -0,0 +1,98 @@ +{ + "swagger": "2.0", + "info": { + "title": "Indego \u0026 Open Weather API Documentation", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/v1/indego-data-fetch-and-store-it-db": { + "post": { + "description": "## Store data from Indego\n\nAn endpoints which downloads fresh data from [Indego GeoJSON station status API](https://www.rideindego.com/stations/json/) and stores it inside PostgreSQL.\n\n```bash\n# this endpoint will be trigger every hour to fetch the data and insert it in the PostgreSQL database\nPOST http://localhost:3000/api/v1/indego-data-fetch-and-store-it-db\n```\n\n### Token \nAdd HTTP header with Authorization \n```go\n\n // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n```\n\n### Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. Check token |\n", + "produces": [ + "application/json" + ], + "tags": [ + "API" + ], + "summary": "Store data from Indego", + "parameters": [ + { + "type": "string", + "description": "Bearer secret_token_static", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": {} + } + }, + "/api/v1/stations": { + "get": { + "description": "## Snapshot of all stations at a specified time\n\nData for all stations as of 11am Universal Coordinated Time on September 1st, 2019:\n\n```bash\nGET http://localhost:3000/api/v1/stations?at=2019-09-01T10:00:00Z\n```\n\nThis endpoint should respond as follows, with the actual time of the first snapshot of data on or after the requested time and the data:\n\n```javascript\n{\n at: '2019-09-01T10:00:00Z',\n stations: { /* As per the Indego API */ },\n weather: { /* As per the Open Weather Map API response for Philadelphia */ }\n}\n```\n\n### Token \nAdd HTTP header with Authorization \n```go\n\n // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n```\n\n### Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. Check token |\n| 404 | Data Not Found |\n", + "produces": [ + "application/json" + ], + "tags": [ + "API" + ], + "summary": "Snapshot of one station at a specific time", + "parameters": [ + { + "type": "string", + "description": "Bearer secret_token_static", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "ex: 2019-09-01T10:00:00Z", + "name": "at", + "in": "query", + "required": true + } + ], + "responses": {} + } + }, + "/api/v1/stations/{kioskId}": { + "get": { + "description": "## Snapshot of one station at a specific time\n\nData for a specific station (by its `kioskId`) at a specific time:\n\n```bash\nGET http://localhost:3000/api/v1/stations/{kioskId}?at=2019-09-01T10:00:00Z\n```\n\nThe response should be the first available on or after the given time, and should look like:\n\n```javascript\n{\n at: '2019-09-01T10:00:00',\n station: { /* Data just for this one station as per the Indego API */ },\n weather: { /* As per the Open Weather Map API response for Philadelphia */ }\n}\n```\n\nInclude an `at` property in the same format indicating the actual time of the snapshot.\n\nIf no suitable data is available a 404 status code should be given.\n\n\n### Token \nAdd HTTP header with Authorization \n```go\n\n // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n```\n\n### Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. Check token |\n| 404 | Data Not Found |\n", + "produces": [ + "application/json" + ], + "tags": [ + "API" + ], + "summary": "Snapshot of all stations at a specified time", + "parameters": [ + { + "type": "string", + "description": "Bearer secret_token_static", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "ex: 2019-09-01T10:00:00Z", + "name": "at", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "ex: 3005", + "name": "kioskId", + "in": "path", + "required": true + } + ], + "responses": {} + } + } + } +} \ No newline at end of file diff --git a/api-gateway/docs/swagger.yaml b/api-gateway/docs/swagger.yaml new file mode 100644 index 0000000..b9dbd60 --- /dev/null +++ b/api-gateway/docs/swagger.yaml @@ -0,0 +1,100 @@ +basePath: / +info: + contact: {} + title: Indego & Open Weather API Documentation + version: "1.0" +paths: + /api/v1/indego-data-fetch-and-store-it-db: + post: + description: "## Store data from Indego\n\nAn endpoints which downloads fresh + data from [Indego GeoJSON station status API](https://www.rideindego.com/stations/json/) + and stores it inside PostgreSQL.\n\n```bash\n# this endpoint will be trigger + every hour to fetch the data and insert it in the PostgreSQL database\nPOST + http://localhost:3000/api/v1/indego-data-fetch-and-store-it-db\n```\n\n### + Token \nAdd HTTP header with Authorization \n```go\n\n // example:\n\theaders + := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n```\n\n### + Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| + 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. + Check token |\n" + parameters: + - description: Bearer secret_token_static + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: {} + summary: Store data from Indego + tags: + - API + /api/v1/stations: + get: + description: "## Snapshot of all stations at a specified time\n\nData for all + stations as of 11am Universal Coordinated Time on September 1st, 2019:\n\n```bash\nGET + http://localhost:3000/api/v1/stations?at=2019-09-01T10:00:00Z\n```\n\nThis + endpoint should respond as follows, with the actual time of the first snapshot + of data on or after the requested time and the data:\n\n```javascript\n{\n + \ at: '2019-09-01T10:00:00Z',\n stations: { /* As per the Indego API */ },\n + \ weather: { /* As per the Open Weather Map API response for Philadelphia + */ }\n}\n```\n\n### Token \nAdd HTTP header with Authorization \n```go\n\n + \ // example:\n\theaders := map[string]string{\n\t\t\"Authorization\": \"Bearer + secret_token_static\",\n\t},\n```\n\n### Response Code\n| HTTP | Description + \ |\n|------|----------------------------------------|\n| + 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. + Check token |\n| 404 | Data Not Found |\n" + parameters: + - description: Bearer secret_token_static + in: header + name: Authorization + required: true + type: string + - description: 'ex: 2019-09-01T10:00:00Z' + in: query + name: at + required: true + type: string + produces: + - application/json + responses: {} + summary: Snapshot of one station at a specific time + tags: + - API + /api/v1/stations/{kioskId}: + get: + description: "## Snapshot of one station at a specific time\n\nData for a specific + station (by its `kioskId`) at a specific time:\n\n```bash\nGET http://localhost:3000/api/v1/stations/{kioskId}?at=2019-09-01T10:00:00Z\n```\n\nThe + response should be the first available on or after the given time, and should + look like:\n\n```javascript\n{\n at: '2019-09-01T10:00:00',\n station: { + /* Data just for this one station as per the Indego API */ },\n weather: + { /* As per the Open Weather Map API response for Philadelphia */ }\n}\n```\n\nInclude + an `at` property in the same format indicating the actual time of the snapshot.\n\nIf + no suitable data is available a 404 status code should be given.\n\n\n### + Token \nAdd HTTP header with Authorization \n```go\n\n // example:\n\theaders + := map[string]string{\n\t\t\"Authorization\": \"Bearer secret_token_static\",\n\t},\n```\n\n### + Response Code\n| HTTP | Description |\n|------|----------------------------------------|\n| + 200 | Fetch and store to database is success |\n| 401 | Bad Authorization. + Check token |\n| 404 | Data Not Found |\n" + parameters: + - description: Bearer secret_token_static + in: header + name: Authorization + required: true + type: string + - description: 'ex: 2019-09-01T10:00:00Z' + in: query + name: at + required: true + type: string + - description: 'ex: 3005' + in: path + name: kioskId + required: true + type: string + produces: + - application/json + responses: {} + summary: Snapshot of all stations at a specified time + tags: + - API +swagger: "2.0" diff --git a/api-gateway/go.mod b/api-gateway/go.mod new file mode 100644 index 0000000..ee06b9d --- /dev/null +++ b/api-gateway/go.mod @@ -0,0 +1,75 @@ +module github.com/arthben/BackendGolang/api-gateway + +go 1.22.5 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/lib/pq v1.10.9 + github.com/rs/zerolog v1.33.0 + github.com/spf13/viper v1.19.0 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.4 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + golang.org/x/tools v0.13.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files v1.0.1 + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/api-gateway/go.sum b/api-gateway/go.sum new file mode 100644 index 0000000..f3784c3 --- /dev/null +++ b/api-gateway/go.sum @@ -0,0 +1,231 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +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/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +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/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-20210420072515-93ed5bcd2bfe/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.6/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/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/api-gateway/internal/client/client.go b/api-gateway/internal/client/client.go new file mode 100644 index 0000000..e1bc1f2 --- /dev/null +++ b/api-gateway/internal/client/client.go @@ -0,0 +1,63 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/rs/zerolog/log" +) + +func SendHTTPRequest( + httpMethod string, + timeout time.Duration, + headers map[string]string, + url string, +) ([]byte, int, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + client := &http.Client{} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Error().Err(err).Msg("SendHTTPRequest -> NewRequestWithContext") + return nil, http.StatusInternalServerError, errors.New("Error creating request") + } + + // Add headers to the request + for key, value := range headers { + req.Header.Add(key, value) + } + + resp, err := client.Do(req) + if err != nil { + log.Error().Err(err).Msg("SendHTTPRequest -> client.Do") + if err == context.DeadlineExceeded { + return nil, http.StatusRequestTimeout, errors.New("Request timed out") + } else { + return nil, http.StatusInternalServerError, errors.New("Error sending request") + } + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, fmt.Errorf("Unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Msg("SendHTTPRequest -> io.ReadAll") + return nil, http.StatusInternalServerError, errors.New("Error reading response body") + } + + return body, http.StatusOK, nil +} + +func GetJSON(body []byte, v interface{}) error { + err := json.Unmarshal(body, v) + return err +} diff --git a/api-gateway/internal/config/config.go b/api-gateway/internal/config/config.go new file mode 100644 index 0000000..be7dcab --- /dev/null +++ b/api-gateway/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + + "github.com/spf13/viper" +) + +type EnvParams struct { + App struct { + Port string `yaml:"port"` + WaitTimeOut int `yaml:"waitTimeout"` + } `yaml:"app"` + DB struct { + DSN string `yaml:"dsn"` + MinPool int `yaml:"minPool"` + MaxPool int `yaml:"maxPool"` + } `yaml:"db"` + OpenWeather struct { + APIKey string `yaml:"apikey"` + } `yaml:"openweather"` +} + +func LoadConfig() (*EnvParams, error) { + // read config file + viper.AddConfigPath("config") + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + return nil, err + } + + // read OS env to fill config file parameter + for _, key := range viper.AllKeys() { + val := viper.GetString(key) + viper.Set(key, os.ExpandEnv(val)) + } + + var cfg EnvParams + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/api-gateway/internal/database/database.go b/api-gateway/internal/database/database.go new file mode 100644 index 0000000..052be92 --- /dev/null +++ b/api-gateway/internal/database/database.go @@ -0,0 +1,194 @@ +package database + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/arthben/BackendGolang/api-gateway/internal/config" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" + + _ "github.com/lib/pq" +) + +type DBService interface { + Close() error + StoreRideIndego(context.Context, ParamStoreRideIndego) error + SearchRideIndego(ctx context.Context, at time.Time, kioskID string) (SearchResRideIndego, error) + StoreOpenWeather(ctx context.Context, master *OpenWeatherMaster, details []*OpenWewatherDetail) error + SearchOpenWeather(ctx context.Context, at time.Time) (SearchResOpenWeather, error) +} + +type dbase struct { + db *sqlx.DB +} + +func NewPool(cfg *config.EnvParams) (DBService, error) { + // create connection and maintain pool internaly + db, err := sqlx.Connect("postgres", cfg.DB.DSN) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(cfg.DB.MaxPool) + db.SetMaxIdleConns(cfg.DB.MinPool) + + return &dbase{db: db}, nil +} + +func (d *dbase) Close() error { + return d.db.Close() +} + +func (d *dbase) SearchOpenWeather( + ctx context.Context, + at time.Time, +) (searchResult SearchResOpenWeather, err error) { + findme := readOpenWeather{db: d.db, ctx: ctx} + searchResult.Master, err = findme.readMaster(at) + if err != nil { + handleError("readMaster", err) + } + + searchResult.Detail, err = findme.readDetail(searchResult.Master.FetchID) + if err != nil { + handleError("readDetail", err) + } + + return +} + +func (d *dbase) SearchRideIndego( + ctx context.Context, + at time.Time, + kioskID string, +) (searchResult SearchResRideIndego, err error) { + + var ( + kiosk int = -1 + featureID int = -1 + ) + + findme := readRideIndego{db: d.db, ctx: ctx} + + if len(kioskID) > 0 { + if kiosk, err = strconv.Atoi(kioskID); err != nil { + err = errors.New("invalid kioskID") + return + } + + masterExtended := RideIndegoMasterExtends{} + if err = findme.readMaster(at, kiosk, &masterExtended); err != nil { + handleError("readMaster", err) + return + } + + featureID = masterExtended.FeatureID + searchResult.Master = &RideIndegoMaster{ + FetchID: masterExtended.FetchID, + TypeCollection: masterExtended.TypeCollection, + LastUpdate: masterExtended.LastUpdate, + } + + } else { + master := RideIndegoMaster{} + if err = findme.readMaster(at, kiosk, &master); err != nil { + return + } + searchResult.Master = &master + } + + // find features data + searchResult.Features, err = findme.readFeatures(searchResult.Master.FetchID, featureID) + if err != nil { + handleError("readFeatures", err) + } + + // find properties data + searchResult.Properties, err = findme.readProperties(searchResult.Master.FetchID, featureID) + if err != nil { + handleError("readProperties", err) + } + + // find properti bikes + searchResult.PropertiesBikes, err = findme.readPropBikes(searchResult.Master.FetchID, featureID) + if err != nil { + handleError("readPropertiesBikes", err) + } + + return +} + +func (d *dbase) StoreRideIndego(ctx context.Context, pInput ParamStoreRideIndego) (err error) { + + tx, err := d.db.BeginTxx(ctx, nil) + if err != nil { + return + } + + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + storeData := storeRideIndeGo{tx: tx, ctx: ctx} + if err = storeData.insertMaster(pInput.Master); err != nil { + handleError("insertMaster", err) + return + } + if err = storeData.insertFeatures(pInput.Features); err != nil { + handleError("insertFeatures", err) + return + } + if err = storeData.insertProperties(pInput.Properties); err != nil { + handleError("insertProperties", err) + return + } + if err = storeData.insertPropertiesBike(pInput.PropertiesBikes); err != nil { + handleError("insertPropertiesBike", err) + return + } + if err = tx.Commit(); err != nil { + return + } + + return nil +} + +func handleError(msg string, err error) { + log.Error().Err(err).Msg(msg) + fmt.Printf("%v - err: %v\n", msg, err) +} + +func (d *dbase) StoreOpenWeather(ctx context.Context, master *OpenWeatherMaster, detail []*OpenWewatherDetail) (err error) { + tx, err := d.db.BeginTxx(ctx, nil) + if err != nil { + return + } + + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + storeData := storeWeather{tx: tx, ctx: ctx} + if err = storeData.insertMaster(master); err != nil { + handleError("inserMaster", err) + return + } + if err = storeData.insertDetail(detail); err != nil { + handleError("insertDetail", err) + return + } + + if err = tx.Commit(); err != nil { + return + } + + return nil +} diff --git a/api-gateway/internal/database/openweatherTables.go b/api-gateway/internal/database/openweatherTables.go new file mode 100644 index 0000000..c1b9525 --- /dev/null +++ b/api-gateway/internal/database/openweatherTables.go @@ -0,0 +1,113 @@ +package database + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" +) + +type SearchResOpenWeather struct { + Master *OpenWeatherMaster + Detail []*OpenWewatherDetail +} + +type OpenWewatherDetail struct { + FetchID string `db:"fetch_id"` + Index int `db:"idx"` + Description string `db:"description"` + Icon string `db:"icon"` + ID int `db:"id"` + Main string `db:"main"` +} + +type OpenWeatherMaster struct { + FetchID string `db:"fetch_id"` + Base string `db:"base"` + Clouds int `db:"clouds"` + COD int `db:"cod"` + Coord string `db:"coord"` + DT int `db:"dt"` + ID int `db:"id"` + MainFeelsLike float64 `db:"main_feels_like"` + MainGrndLevel int `db:"main_grnd_level"` + MainHumidity int `db:"main_humidity"` + MainPressure int `db:"main_pressure"` + MainSeaLevel int `db:"main_sea_level"` + MainTemp float64 `db:"main_temp"` + MainTempMax float64 `db:"main_temp_max"` + MainTempMin float64 `db:"main_temp_min"` + Name string `db:"name"` + RainOneHour float64 `db:"rain_one_hour"` + SysCountry string `db:"sys_country"` + SysID int `db:"sys_id"` + SysSunrise int `db:"sys_sunrise"` + SysSunset int `db:"sys_sunset"` + SysType int `db:"sys_type"` + Timezone int `db:"timezone"` + Visibility int `db:"visibility"` + WindDeg int `db:"wind_deg"` + WindSpeed float64 `db:"wind_speed"` +} + +type readOpenWeather struct { + db *sqlx.DB + ctx context.Context +} + +func (r *readOpenWeather) readMaster(at time.Time) (*OpenWeatherMaster, error) { + sql := `SELECT fetch_id, base, clouds, cod, ST_AsText(coord) AS coord, dt, id, + main_feels_like, main_grnd_level, main_humidity, main_pressure, + main_sea_level, main_temp, main_temp_max, main_temp_min, "name", + rain_one_hour, sys_country, sys_id, sys_sunrise, sys_sunset, + sys_type, timezone, visibility, wind_deg, wind_speed + FROM openweather_master + WHERE dt >= $1 + LIMIT 1` + + var master OpenWeatherMaster + err := r.db.GetContext(r.ctx, &master, sql, at.Unix()) + return &master, err +} + +func (r *readOpenWeather) readDetail(fetchID string) ([]*OpenWewatherDetail, error) { + sql := `SELECT fetch_id, idx, description, icon, id, main + FROM openweather_weather + WHERE fetch_id = $1` + + var detail []*OpenWewatherDetail + err := r.db.SelectContext(r.ctx, &detail, sql, fetchID) + return detail, err +} + +type storeWeather struct { + tx *sqlx.Tx + ctx context.Context +} + +func (s *storeWeather) insertMaster(master *OpenWeatherMaster) error { + sql := `INSERT INTO openweather_master + (fetch_id, base, clouds, cod, coord, dt, id, main_feels_like, + main_grnd_level, main_humidity, main_pressure, main_sea_level, + main_temp, main_temp_max, main_temp_min, "name", rain_one_hour, + sys_country, sys_id, sys_sunrise, sys_sunset, sys_type, timezone, + visibility, wind_deg, wind_speed) + VALUES + (:fetch_id, :base,:clouds, :cod, ST_GeomFromText(:coord, 4326), + :dt, :id, :main_feels_like, + :main_grnd_level, :main_humidity, :main_pressure, :main_sea_level, + :main_temp, :main_temp_max, :main_temp_min, :name, :rain_one_hour, + :sys_country, :sys_id, :sys_sunrise, :sys_sunset, :sys_type, :timezone, + :visibility, :wind_deg, :wind_speed)` + _, err := s.tx.NamedExecContext(s.ctx, sql, master) + return err +} + +func (s *storeWeather) insertDetail(detail []*OpenWewatherDetail) error { + sql := `INSERT INTO openweather_weather + (fetch_id, description, icon, id, main) + VALUES + (:fetch_id, :description, :icon, :id, :main)` + _, err := s.tx.NamedExecContext(s.ctx, sql, detail) + return err +} diff --git a/api-gateway/internal/database/rideIndegoTables.go b/api-gateway/internal/database/rideIndegoTables.go new file mode 100644 index 0000000..70fe0eb --- /dev/null +++ b/api-gateway/internal/database/rideIndegoTables.go @@ -0,0 +1,298 @@ +package database + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" +) + +// Parameter for store data +type ParamStoreRideIndego struct { + Master *RideIndegoMaster + Features []*RideIndegoFeatures + Properties []*RideIndegoProperties + PropertiesBikes []*RideIndegoBikes +} + +type SearchResRideIndego struct { + Master *RideIndegoMaster + Features []*RideIndegoFeatures + Properties map[int]*RideIndegoProperties + PropertiesBikes map[int][]*RideIndegoBikes +} + +// Structure table rideindego_properties_bikes +type RideIndegoBikes struct { + FetchID string `db:"fetch_id"` + FeatureID int `db:"feat_id"` + PropertiesID int `db:"id"` + Index int `db:"idx"` + Battery int `db:"battery"` + DockNumber int `db:"dock_number"` + IsElectric bool `db:"is_electric"` + IsAvailable bool `db:"is_available"` +} + +// Structure table rideindego_properties +type RideIndegoProperties struct { + FetchID string `db:"fetch_id"` + FeatureID int `db:"feat_id"` + PropertiesID int `db:"id"` + Name string `db:"name"` + Notes string `db:"notes"` + KioskID int `db:"kiosk_id"` + EventEnd string `db:"event_end"` + Latitude float64 `db:"latitude"` + OpenTime string `db:"open_time"` + TimeZone string `db:"time_zone"` + CloseTime string `db:"close_time"` + IsVirtual bool `db:"is_virtual"` + KioskType int `db:"kiosk_type"` + Longitude float64 `db:"longitude"` + EventStart string `db:"event_start"` + PublicText string `db:"public_text"` + TotalDocks int `db:"total_docks"` + AddressCity string `db:"address_city"` + Coordinates string `db:"coordinates"` + KioskStatus string `db:"kiosk_status"` + AddressState string `db:"address_state"` + IsEventBased bool `db:"is_event_based"` + AddressStreet string `db:"address_street"` + AddressZipCode string `db:"address_zip_code"` + BikesAvailable int `db:"bikes_available"` + DocksAvailable int `db:"docks_available"` + TrikesAvailable int `db:"trikes_available"` + KioskPublicStatus string `db:"kiosk_public_status"` + SmartBikesAvailable int `db:"smart_bikes_available"` + RewardBikesAvailable int `db:"reward_bikes_available"` + RewardDocksAvailable int `db:"reward_docks_available"` + ClassicBikesAvailable int `db:"classic_bikes_available"` + KioskConnectionStatus string `db:"kiosk_connection_status"` + ElectricBikesAvailable int `db:"electric_bikes_available"` +} + +// Structure table rideindego_features +type RideIndegoFeatures struct { + FetchID string `db:"fetch_id"` + FeatureID int `db:"feat_id"` + FeatureType string `db:"ftype"` + GeometryType string `db:"geo_type"` + GeometryCoordinate string `db:"geo_coordinate"` +} + +// Structure table rideindego_master +type RideIndegoMaster struct { + FetchID string `db:"fetch_id"` + TypeCollection string `db:"type_collection"` + LastUpdate time.Time `db:"last_update"` +} + +// this struct only for store temporary +type RideIndegoMasterExtends struct { + FetchID string `db:"fetch_id"` + TypeCollection string `db:"type_collection"` + LastUpdate time.Time `db:"last_update"` + FeatureID int `db:"feat_id"` +} + +type readRideIndego struct { + db *sqlx.DB + ctx context.Context +} + +func withKioskID(kioskID int) bool { + return kioskID != -1 +} + +func withFeatureID(featureID int) bool { + return featureID != -1 +} + +func (r *readRideIndego) readMaster(at time.Time, kioskID int, v interface{}) error { + var ( + sql string + args []interface{} + ) + + // time parameter is mandatory + args = append(args, at) + + if withKioskID(kioskID) { + // find based on kioskID + sql = `SELECT m.fetch_id, m.type_collection, m.last_update, p.feat_id + FROM rideindego_master m + LEFT OUTER JOIN rideindego_properties p on p.fetch_id=m.fetch_id + WHERE m.last_update >= $1 and p.kiosk_id = $2 + ORDER by m.last_update ASC + LIMIT 1` + args = append(args, kioskID) + + } else { + // find only based on time + sql = `SELECT fetch_id, type_collection, last_update + FROM rideindego_master + WHERE last_update >= $1 + ORDER by last_update ASC + LIMIT 1` + } + + err := r.db.GetContext(r.ctx, v, sql, args...) + return err +} + +func (r *readRideIndego) readFeatures(fetchID string, featureID int) ([]*RideIndegoFeatures, error) { + var args []interface{} + + // fetchID is mandatory + args = append(args, fetchID) + + sql := `SELECT fetch_id, feat_id, ftype, geo_type, ST_AsText(geo_coordinate) AS geo_coordinate + FROM rideindego_features + WHERE fetch_id=$1` + + if withFeatureID(featureID) { + sql += " AND feat_id=$2" + args = append(args, featureID) + } + + var features []*RideIndegoFeatures + err := r.db.SelectContext(r.ctx, &features, sql, args...) + return features, err +} + +func (r *readRideIndego) readProperties(fetchID string, featureID int) (map[int]*RideIndegoProperties, error) { + var args []interface{} + + // fetchID is mandatory + args = append(args, fetchID) + + sql := `SELECT fetch_id, feat_id, id, "name", notes, kiosk_id, event_end, + latitude, open_time, time_zone, close_time, is_virtual, kiosk_type, + longitude, event_start, public_text, total_docks, address_city, + ST_AsText(coordinates) AS coordinates, + kiosk_status, address_state, is_event_based, address_street, + address_zip_code, bikes_available, docks_available, trikes_available, + kiosk_public_status, smart_bikes_available, reward_bikes_available, + reward_docks_available, classic_bikes_available, kiosk_connection_status, + electric_bikes_available + FROM rideindego_properties + WHERE fetch_id=$1` + + if withFeatureID(featureID) { + sql += " AND feat_id=$2" + args = append(args, featureID) + } + + rows, err := r.db.QueryxContext(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + properties := make(map[int]*RideIndegoProperties, 0) + for rows.Next() { + prop := RideIndegoProperties{} + err := rows.StructScan(&prop) + if err != nil { + return nil, err + } + + properties[prop.FeatureID] = &prop + } + + return properties, nil +} + +func (r *readRideIndego) readPropBikes(fetchID string, featureID int) (map[int][]*RideIndegoBikes, error) { + var args []interface{} + + // fetchID is mandatory + args = append(args, fetchID) + + sql := `SELECT fetch_id, feat_id, id, idx, battery, dock_number, is_electric, is_available + FROM rideindego_properties_bikes + WHERE fetch_id=$1` + + if withFeatureID(featureID) { + sql += " AND feat_id=$2" + args = append(args, featureID) + } + + rows, err := r.db.QueryxContext(r.ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + bikes := make(map[int][]*RideIndegoBikes, 0) + for rows.Next() { + bike := RideIndegoBikes{} + err := rows.StructScan(&bike) + if err != nil { + return nil, err + } + + key := bike.FeatureID + bikes[key] = append(bikes[key], &bike) + } + + return bikes, nil +} + +type storeRideIndeGo struct { + tx *sqlx.Tx + ctx context.Context +} + +func (s *storeRideIndeGo) insertMaster(master *RideIndegoMaster) error { + sql := `INSERT INTO rideindego_master + (fetch_id, type_collection, last_update) + VALUES + (:fetch_id, :type_collection, :last_update)` + + _, err := s.tx.NamedExecContext(s.ctx, sql, master) + return err +} + +func (s *storeRideIndeGo) insertFeatures(features []*RideIndegoFeatures) error { + sql := `INSERT INTO rideindego_features + (fetch_id, feat_id, ftype, geo_type, geo_coordinate) + VALUES + (:fetch_id, :feat_id, :ftype, :geo_type, ST_GeomFromText(:geo_coordinate, 4326))` + _, err := s.tx.NamedExecContext(s.ctx, sql, features) + return err +} + +func (s *storeRideIndeGo) insertProperties(properties []*RideIndegoProperties) error { + sql := `INSERT INTO rideindego_properties + (fetch_id, feat_id, id, "name", notes, kiosk_id, event_end, latitude, + open_time, time_zone, close_time, is_virtual, kiosk_type, longitude, + event_start, public_text, total_docks, address_city, coordinates, + kiosk_status, address_state, is_event_based, address_street, + address_zip_code, bikes_available, docks_available, trikes_available, + kiosk_public_status, smart_bikes_available, reward_bikes_available, + reward_docks_available, classic_bikes_available, kiosk_connection_status, + electric_bikes_available) + VALUES + (:fetch_id, :feat_id, :id, :name, :notes, :kiosk_id, :event_end, :latitude, + :open_time, :time_zone, :close_time, :is_virtual, :kiosk_type, :longitude, + :event_start, :public_text, :total_docks, :address_city, + ST_GeomFromText(:coordinates, 4326), + :kiosk_status, :address_state, :is_event_based, :address_street, + :address_zip_code, :bikes_available, :docks_available, :trikes_available, + :kiosk_public_status, :smart_bikes_available, :reward_bikes_available, + :reward_docks_available, :classic_bikes_available, :kiosk_connection_status, + :electric_bikes_available)` + _, err := s.tx.NamedExecContext(s.ctx, sql, properties) + return err +} + +func (s *storeRideIndeGo) insertPropertiesBike(propBikes []*RideIndegoBikes) error { + sql := `INSERT INTO rideindego_properties_bikes + (fetch_id, feat_id, id, battery, dock_number, is_electric, is_available) + VALUES + (:fetch_id, :feat_id, :id, :battery, :dock_number, :is_electric, :is_available)` + _, err := s.tx.NamedExecContext(s.ctx, sql, propBikes) + return err +} diff --git a/api-gateway/scripts/dbInit/database.sql b/api-gateway/scripts/dbInit/database.sql new file mode 100644 index 0000000..140cb13 --- /dev/null +++ b/api-gateway/scripts/dbInit/database.sql @@ -0,0 +1,109 @@ +CREATE EXTENSION postgis; + +create table rideindego_master ( + fetch_id uuid PRIMARY KEY, + type_collection varchar(25) not NULL, + last_update TIMESTAMP WITH TIME zone not NULL +); + + +create table rideindego_features ( + fetch_id uuid not null, + feat_id integer not null, + ftype varchar(25) not null, + geo_type varchar(10) not null, + geo_coordinate GEOMETRY(Point,4326), + primary key(fetch_id, feat_id) +); + + +create table rideindego_properties( + fetch_id uuid not null, + feat_id integer not null, + id integer not null, + name varchar, + notes varchar default null, + kiosk_id integer, + event_end varchar default null, + latitude float, + open_time varchar default null, + time_zone varchar default null, + close_time varchar default null, + is_virtual bool, + kiosk_type integer, + longitude float, + event_start varchar default null, + public_text varchar, + total_docks integer, + address_city varchar, + coordinates GEOMETRY(Point,4326), + kiosk_status varchar, + address_state varchar, + is_event_based bool, + address_street varchar, + address_zip_code varchar, + bikes_available integer, + docks_available integer, + trikes_available integer, + kiosk_public_status varchar, + smart_bikes_available integer, + reward_bikes_available integer, + reward_docks_available integer, + classic_bikes_available integer, + kiosk_connection_status varchar, + electric_bikes_available integer, + primary key(fetch_id, feat_id, id) +); +create index idx_kioskid on rideindego_properties(kiosk_id); + +create table rideindego_properties_bikes( + fetch_id uuid not null, + feat_id integer not null, + id integer not null, + idx SERIAL not null, + battery INTEGER, + dock_number INTEGER, + is_electric bool, + is_available bool, + primary key(fetch_id, feat_id, id, idx) +); + +create table openweather_master ( + fetch_id uuid not null, + base varchar, + clouds integer, + cod integer, + coord GEOMETRY(Point,4326), + dt integer, + id integer, + main_feels_like float, + main_grnd_level integer, + main_humidity integer, + main_pressure integer, + main_sea_level integer, + main_temp float, + main_temp_max float, + main_temp_min float, + name varchar, + rain_one_hour float, + sys_country varchar, + sys_id integer, + sys_sunrise integer, + sys_sunset integer, + sys_type integer, + timezone integer, + visibility integer, + wind_deg integer, + wind_speed float, + primary key(fetch_id) +); + +create table openweather_weather( + fetch_id uuid not null, + idx serial not null, + description varchar, + icon varchar, + id integer, + main varchar, + primary key(fetch_id, idx) +); \ No newline at end of file diff --git a/api-gateway/scripts/localEnv/set_env.sh b/api-gateway/scripts/localEnv/set_env.sh new file mode 100644 index 0000000..2de3955 --- /dev/null +++ b/api-gateway/scripts/localEnv/set_env.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# DB_HOST="postgresdb" +DB_HOST="127.0.0.1" +DB_DRIVER="postgres" +# DB_USER="epiphyte" +DB_USER="postgres" +DB_PASSWORD="3p1phyte-corp.com" +# DB_NAME="backendGo" +DB_NAME=postgres +DB_PORT="5432" +DB_MIN_POOL="1" +DB_MAX_POOL="10" + +DB_DSN="${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable&timezone=UTC" + +export DB_HOST +export DB_DRIVER +export DB_USER +export DB_PASSWORD +export DB_NAME +export DB_PORT +export DB_MIN_POOL +export DB_MAX_POOL +export DB_DSN + + +# set ini di env docker +SERVER_PORT=3000 +SERVER_TIMEOUT=30 + +export SERVER_PORT +export SERVER_TIMEOUT + + +# OPENWEATHER_APIKEY=598eb70eacf1d53a1eec6ef3e6da25c2 +OPENWEATHER_APIKEY=e4d25c020947523c7c18b8e4af1ce00e + +export OPENWEATHER_APIKEY diff --git a/api-gateway/scripts/localEnv/unset_env.sh b/api-gateway/scripts/localEnv/unset_env.sh new file mode 100644 index 0000000..16a4108 --- /dev/null +++ b/api-gateway/scripts/localEnv/unset_env.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +unset DB_HOST +unset DB_DRIVER +unset DB_USER +unset DB_PASSWORD +unset DB_NAME +unset DB_PORT + +unset DB_MIN_POOL +unset DB_MAX_POOL +unset DB_DSN + +unset SERVER_PORT +unset SERVER_TIMEOUT diff --git a/api-gateway/swagger-markdown/data-fetch.md b/api-gateway/swagger-markdown/data-fetch.md new file mode 100644 index 0000000..86ad5a3 --- /dev/null +++ b/api-gateway/swagger-markdown/data-fetch.md @@ -0,0 +1,24 @@ +## Store data from Indego + +An endpoints which downloads fresh data from [Indego GeoJSON station status API](https://www.rideindego.com/stations/json/) and stores it inside PostgreSQL. + +```bash +# this endpoint will be trigger every hour to fetch the data and insert it in the PostgreSQL database +POST http://localhost:3000/api/v1/indego-data-fetch-and-store-it-db +``` + +### Token +Add HTTP header with Authorization +```go + + // example: + headers := map[string]string{ + "Authorization": "Bearer secret_token_static", + }, +``` + +### Response Code +| HTTP | Description | +|------|----------------------------------------| +| 200 | Fetch and store to database is success | +| 401 | Bad Authorization. Check token | diff --git a/api-gateway/swagger-markdown/stations.md b/api-gateway/swagger-markdown/stations.md new file mode 100644 index 0000000..f6e39d0 --- /dev/null +++ b/api-gateway/swagger-markdown/stations.md @@ -0,0 +1,34 @@ +## Snapshot of all stations at a specified time + +Data for all stations as of 11am Universal Coordinated Time on September 1st, 2019: + +```bash +GET http://localhost:3000/api/v1/stations?at=2019-09-01T10:00:00Z +``` + +This endpoint should respond as follows, with the actual time of the first snapshot of data on or after the requested time and the data: + +```javascript +{ + at: '2019-09-01T10:00:00Z', + stations: { /* As per the Indego API */ }, + weather: { /* As per the Open Weather Map API response for Philadelphia */ } +} +``` + +### Token +Add HTTP header with Authorization +```go + + // example: + headers := map[string]string{ + "Authorization": "Bearer secret_token_static", + }, +``` + +### Response Code +| HTTP | Description | +|------|----------------------------------------| +| 200 | Fetch and store to database is success | +| 401 | Bad Authorization. Check token | +| 404 | Data Not Found | diff --git a/api-gateway/swagger-markdown/stationsKioskId.md b/api-gateway/swagger-markdown/stationsKioskId.md new file mode 100644 index 0000000..be0ac27 --- /dev/null +++ b/api-gateway/swagger-markdown/stationsKioskId.md @@ -0,0 +1,39 @@ +## Snapshot of one station at a specific time + +Data for a specific station (by its `kioskId`) at a specific time: + +```bash +GET http://localhost:3000/api/v1/stations/{kioskId}?at=2019-09-01T10:00:00Z +``` + +The response should be the first available on or after the given time, and should look like: + +```javascript +{ + at: '2019-09-01T10:00:00', + station: { /* Data just for this one station as per the Indego API */ }, + weather: { /* As per the Open Weather Map API response for Philadelphia */ } +} +``` + +Include an `at` property in the same format indicating the actual time of the snapshot. + +If no suitable data is available a 404 status code should be given. + + +### Token +Add HTTP header with Authorization +```go + + // example: + headers := map[string]string{ + "Authorization": "Bearer secret_token_static", + }, +``` + +### Response Code +| HTTP | Description | +|------|----------------------------------------| +| 200 | Fetch and store to database is success | +| 401 | Bad Authorization. Check token | +| 404 | Data Not Found |