diff --git a/.golangci.yaml b/.golangci.yaml index 8649d32..37b688c 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -7,5 +7,4 @@ linters: - tagliatelle - wsl # deprecated - varnamelen - build-tags: - - integration \ No newline at end of file + - prealloc diff --git a/Makefile b/Makefile index 16f0dc1..50adc9a 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,10 @@ test: .PHONY: validate validate: fix test +.PHONY: generate +generate: + make -C api generate + .PHONY: cover cover: go test ./... --coverpkg ./... -coverprofile=c.out diff --git a/api/Makefile b/api/Makefile new file mode 100644 index 0000000..6f91bcc --- /dev/null +++ b/api/Makefile @@ -0,0 +1,4 @@ +.PHONY: generate +generate: + bunx tsp compile . + oapi-codegen -package=api -generate=chi-server,models,strict-server -o gen.go tsp-output/schema/openapi.yaml \ No newline at end of file diff --git a/api/gen.go b/api/gen.go new file mode 100644 index 0000000..ab90f24 --- /dev/null +++ b/api/gen.go @@ -0,0 +1,431 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" + strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" +) + +// Defines values for WeatherCondition. +const ( + CLEAR WeatherCondition = "CLEAR" + CLOUDY WeatherCondition = "CLOUDY" + RAIN WeatherCondition = "RAIN" + RAINSNOW WeatherCondition = "RAIN_SNOW" + SNOW WeatherCondition = "SNOW" +) + +// Celsius 섭씨 온도(℃) +type Celsius = float32 + +// DailyForecast defines model for DailyForecast. +type DailyForecast struct { + // Condition 하루 전체를 대표하는 날씨 상태 + Condition WeatherCondition `json:"condition"` + + // Date 해당 날짜 + Date time.Time `json:"date"` + + // High 하루 동안의 예상 최고 기온(℃) + High Celsius `json:"high"` + + // Low 하루 동안의 예상 최저 기온(℃) + Low Celsius `json:"low"` +} + +// DailyForecastList defines model for DailyForecastList. +type DailyForecastList struct { + Items []DailyForecast `json:"items"` +} + +// Error defines model for Error. +type Error struct { + // ErrorCode 에러 코드 + ErrorCode int32 `json:"errorCode"` + + // ErrorMessage 에러 메시지 + ErrorMessage string `json:"errorMessage"` +} + +// HourlyForecast defines model for HourlyForecast. +type HourlyForecast struct { + // Condition 1시간 동안의 예상 날씨 상태 + Condition WeatherCondition `json:"condition"` + + // Temperature 1시간 동안의 예상 기온(℃) + Temperature Celsius `json:"temperature"` + + // Time 예상 시간 + Time time.Time `json:"time"` +} + +// HourlyForecastList defines model for HourlyForecastList. +type HourlyForecastList struct { + Items []HourlyForecast `json:"items"` +} + +// WeatherCondition defines model for WeatherCondition. +type WeatherCondition string + +// ForecastsListDailyParams defines parameters for ForecastsListDaily. +type ForecastsListDailyParams struct { + // Timezone 타임존 (기본값: Asia/Seoul) + Timezone *string `form:"timezone,omitempty" json:"timezone,omitempty"` + + // Now 현재 시간 (기본값: 현재 시간) + Now *time.Time `form:"now,omitempty" json:"now,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (GET /forecasts/daily) + ForecastsListDaily(w http.ResponseWriter, r *http.Request, params ForecastsListDailyParams) + + // (GET /forecasts/hourly) + ForecastsListHourly(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// (GET /forecasts/daily) +func (_ Unimplemented) ForecastsListDaily(w http.ResponseWriter, r *http.Request, params ForecastsListDailyParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (GET /forecasts/hourly) +func (_ Unimplemented) ForecastsListHourly(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// ForecastsListDaily operation middleware +func (siw *ServerInterfaceWrapper) ForecastsListDaily(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ForecastsListDailyParams + + // ------------- Optional query parameter "timezone" ------------- + + err = runtime.BindQueryParameter("form", false, false, "timezone", r.URL.Query(), ¶ms.Timezone) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "timezone", Err: err}) + return + } + + // ------------- Optional query parameter "now" ------------- + + err = runtime.BindQueryParameter("form", false, false, "now", r.URL.Query(), ¶ms.Now) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "now", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ForecastsListDaily(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ForecastsListHourly operation middleware +func (siw *ServerInterfaceWrapper) ForecastsListHourly(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ForecastsListHourly(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/forecasts/daily", wrapper.ForecastsListDaily) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/forecasts/hourly", wrapper.ForecastsListHourly) + }) + + return r +} + +type ForecastsListDailyRequestObject struct { + Params ForecastsListDailyParams +} + +type ForecastsListDailyResponseObject interface { + VisitForecastsListDailyResponse(w http.ResponseWriter) error +} + +type ForecastsListDaily200JSONResponse DailyForecastList + +func (response ForecastsListDaily200JSONResponse) VisitForecastsListDailyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ForecastsListDaily500JSONResponse Error + +func (response ForecastsListDaily500JSONResponse) VisitForecastsListDailyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type ForecastsListHourlyRequestObject struct { +} + +type ForecastsListHourlyResponseObject interface { + VisitForecastsListHourlyResponse(w http.ResponseWriter) error +} + +type ForecastsListHourly200JSONResponse HourlyForecastList + +func (response ForecastsListHourly200JSONResponse) VisitForecastsListHourlyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ForecastsListHourly500JSONResponse Error + +func (response ForecastsListHourly500JSONResponse) VisitForecastsListHourlyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + + // (GET /forecasts/daily) + ForecastsListDaily(ctx context.Context, request ForecastsListDailyRequestObject) (ForecastsListDailyResponseObject, error) + + // (GET /forecasts/hourly) + ForecastsListHourly(ctx context.Context, request ForecastsListHourlyRequestObject) (ForecastsListHourlyResponseObject, error) +} + +type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc +type StrictMiddlewareFunc = strictnethttp.StrictHTTPMiddlewareFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// ForecastsListDaily operation middleware +func (sh *strictHandler) ForecastsListDaily(w http.ResponseWriter, r *http.Request, params ForecastsListDailyParams) { + var request ForecastsListDailyRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ForecastsListDaily(ctx, request.(ForecastsListDailyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ForecastsListDaily") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ForecastsListDailyResponseObject); ok { + if err := validResponse.VisitForecastsListDailyResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ForecastsListHourly operation middleware +func (sh *strictHandler) ForecastsListHourly(w http.ResponseWriter, r *http.Request) { + var request ForecastsListHourlyRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ForecastsListHourly(ctx, request.(ForecastsListHourlyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ForecastsListHourly") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ForecastsListHourlyResponseObject); ok { + if err := validResponse.VisitForecastsListHourlyResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/main.tsp b/api/main.tsp index 4519a27..3ce3881 100644 --- a/api/main.tsp +++ b/api/main.tsp @@ -62,7 +62,16 @@ model Error { interface Forecasts { @get @route("/daily") - listDaily(): { + @doc("일일 날씨 예보 목록을 조회합니다.") + listDaily( + @query + @doc("타임존 (기본값: Asia/Seoul)") + timezone?: string, + + @query + @doc("현재 시간 (기본값: 현재 시간)") + now?: offsetDateTime, + ): { @statusCode statusCode: 200; @body body: DailyForecastList; } | { diff --git a/cmd/seven-skies/handler.go b/cmd/seven-skies/handler.go new file mode 100644 index 0000000..b5caa1c --- /dev/null +++ b/cmd/seven-skies/handler.go @@ -0,0 +1,67 @@ +//nolint:ireturn +package main + +import ( + "context" + "net/http" + "time" + + "github.com/neatflowcv/seven-skies/api" + "github.com/neatflowcv/seven-skies/internal/app/flow" +) + +var _ api.StrictServerInterface = (*Handler)(nil) + +type Handler struct { + flow *flow.Flow +} + +func NewHandler(flow *flow.Flow) *Handler { + return &Handler{ + flow: flow, + } +} + +func (h *Handler) ForecastsListDaily( + ctx context.Context, + req api.ForecastsListDailyRequestObject, +) (api.ForecastsListDailyResponseObject, error) { + timezone := "Asia/Seoul" + if req.Params.Timezone != nil { + timezone = *req.Params.Timezone + } + + now := time.Now() + if req.Params.Now != nil { + now = *req.Params.Now + } + + weathers, err := h.flow.ListDailyWeathers(ctx, timezone, now) + if err != nil { + return api.ForecastsListDaily500JSONResponse{ //nolint:nilerr + ErrorCode: http.StatusInternalServerError, + ErrorMessage: err.Error(), + }, nil + } + + var items []api.DailyForecast + for _, weather := range weathers { + items = append(items, api.DailyForecast{ + Date: weather.Date, + High: api.Celsius(weather.High), + Low: api.Celsius(weather.Low), + Condition: api.WeatherCondition(weather.Condition), + }) + } + + return api.ForecastsListDaily200JSONResponse{ + Items: items, + }, nil +} + +func (h *Handler) ForecastsListHourly( + ctx context.Context, + req api.ForecastsListHourlyRequestObject, +) (api.ForecastsListHourlyResponseObject, error) { + panic("unimplemented") +} diff --git a/cmd/seven-skies/main.go b/cmd/seven-skies/main.go index f17e32f..fa740cc 100644 --- a/cmd/seven-skies/main.go +++ b/cmd/seven-skies/main.go @@ -5,10 +5,14 @@ import ( "encoding/json" "fmt" "log" + "net/http" "os" "runtime/debug" + "time" + "github.com/go-chi/chi/v5" "github.com/joho/godotenv" + "github.com/neatflowcv/seven-skies/api" "github.com/neatflowcv/seven-skies/internal/app/flow" "github.com/neatflowcv/seven-skies/internal/pkg/broker/nats" "github.com/neatflowcv/seven-skies/internal/pkg/domain" @@ -98,5 +102,25 @@ func main() { log.Panic("error in subscribe", err) } - select {} + err = serve(service) + if err != nil { + log.Panic("error in serve", err) + } +} + +func serve(service *flow.Flow) error { + const timeout = 5 * time.Second + + server := &http.Server{ //nolint:exhaustruct + ReadHeaderTimeout: timeout, + Handler: api.HandlerFromMux(api.NewStrictHandler(NewHandler(service), nil), chi.NewRouter()), + Addr: "0.0.0.0:8080", + } + + err := server.ListenAndServe() + if err != nil { + return fmt.Errorf("error in listen and serve: %w", err) + } + + return nil } diff --git a/go.mod b/go.mod index 9d3810d..33523d2 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/neatflowcv/seven-skies go 1.25.5 require ( + github.com/go-chi/chi/v5 v5.2.3 github.com/go-co-op/gocron/v2 v2.18.2 github.com/joho/godotenv v1.5.1 github.com/nats-io/nats.go v1.47.0 + github.com/oapi-codegen/runtime v1.1.2 github.com/oklog/ulid/v2 v2.1.1 github.com/stretchr/testify v1.11.1 gorm.io/driver/postgres v1.6.0 @@ -14,6 +16,7 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index aaf6f8f..481250f 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q= github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -22,6 +28,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -34,6 +41,8 @@ github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= @@ -44,6 +53,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/app/flow/flow.go b/internal/app/flow/flow.go index 014ba22..0e10f62 100644 --- a/internal/app/flow/flow.go +++ b/internal/app/flow/flow.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "sort" + "time" "github.com/neatflowcv/seven-skies/internal/pkg/domain" "github.com/neatflowcv/seven-skies/internal/pkg/repository" @@ -45,3 +47,86 @@ func (f *Flow) CreateWeather(ctx context.Context, weather *Weather) error { return nil } + +func (f *Flow) ListDailyWeathers( + ctx context.Context, + timezone string, + now time.Time, +) ([]*DailyWeather, error) { + location, err := time.LoadLocation(timezone) + if err != nil { + return nil, fmt.Errorf("error in load location: %w", err) + } + + const aWeek = 7 + + localNow := now.In(location) + + from := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location) + to := from.AddDate(0, 0, aWeek).Add(-time.Nanosecond) + + weathers, err := f.repo.ListWeathers(ctx, from, to) + if err != nil { + return nil, fmt.Errorf("error in list daily weathers: %w", err) + } + + weathersByDate := f.groupByDate(weathers, location) + + var dailyWeathers []*DailyWeather + + for dateKey, weathers := range weathersByDate { + newVar := f.mergeWeathers(dateKey, location, weathers) + dailyWeathers = append(dailyWeathers, newVar) + } + + sort.Slice(dailyWeathers, func(i, j int) bool { + return dailyWeathers[i].Date.Before(dailyWeathers[j].Date) + }) + + return dailyWeathers, nil +} + +func (*Flow) mergeWeathers(dateKey string, location *time.Location, weathers []*domain.Weather) *DailyWeather { + date, _ := time.Parse("2006-01-02", dateKey) + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + + high := float64(weathers[0].Temperature().Value) + low := float64(weathers[0].Temperature().Value) + worstCondition := weathers[0].Condition() + + for _, w := range weathers { + temp := float64(w.Temperature().Value) + if temp > high { + high = temp + } + + if temp < low { + low = temp + } + + if w.Condition().IsWorseThan(worstCondition) { + worstCondition = w.Condition() + } + } + + newVar := &DailyWeather{ + Date: date, + High: high, + Low: low, + Condition: string(worstCondition), + } + + return newVar +} + +func (*Flow) groupByDate(weathers []*domain.Weather, location *time.Location) map[string][]*domain.Weather { + weathersByDate := make(map[string][]*domain.Weather) + + for _, weather := range weathers { + localDate := weather.TargetDate().In(location) + dateKey := time.Date(localDate.Year(), localDate.Month(), localDate.Day(), 0, 0, 0, 0, location).Format("2006-01-02") + weathersByDate[dateKey] = append(weathersByDate[dateKey], weather) + } + + return weathersByDate +} diff --git a/internal/app/flow/weather.go b/internal/app/flow/weather.go index 324e494..7d498a3 100644 --- a/internal/app/flow/weather.go +++ b/internal/app/flow/weather.go @@ -9,3 +9,10 @@ type Weather struct { Condition string Temperature float64 // Celsius } + +type DailyWeather struct { + Date time.Time + High float64 // Celsius + Low float64 // Celsius + Condition string +} diff --git a/internal/pkg/domain/weather_condition.go b/internal/pkg/domain/weather_condition.go index dea11f1..b0c22bd 100644 --- a/internal/pkg/domain/weather_condition.go +++ b/internal/pkg/domain/weather_condition.go @@ -10,3 +10,31 @@ const ( WeatherConditionSnow WeatherCondition = "SNOW" WeatherConditionRainSnow WeatherCondition = "RAIN_SNOW" ) + +// IsWorseThan은 현재 날씨 조건이 other보다 나쁜 조건인지 확인합니다. +// 우선순위: RAIN > RAIN_SNOW > SNOW > CLOUDY > CLEAR. +func (c WeatherCondition) IsWorseThan(other WeatherCondition) bool { + return c.priority() > other.priority() +} + +// priority는 날씨 조건의 우선순위를 반환합니다. +// 우선순위가 높을수록 더 나쁜 날씨 조건입니다. +// 우선순위: RAIN(4) > RAIN_SNOW(3) > SNOW(2) > CLOUDY(1) > CLEAR(0) > UNKNOWN(-1). +func (c WeatherCondition) priority() int { + switch c { + case WeatherConditionClear: + return 0 + case WeatherConditionCloudy: + return 1 + case WeatherConditionSnow: + return 2 //nolint:mnd + case WeatherConditionRainSnow: + return 3 //nolint:mnd + case WeatherConditionRain: + return 4 //nolint:mnd + case WeatherConditionUnknown: + return -1 + default: + return -1 + } +} diff --git a/internal/pkg/repository/gorm/repository.go b/internal/pkg/repository/gorm/repository.go index b3f1181..f0fb0d4 100644 --- a/internal/pkg/repository/gorm/repository.go +++ b/internal/pkg/repository/gorm/repository.go @@ -3,6 +3,7 @@ package gorm import ( "context" "fmt" + "time" "github.com/neatflowcv/seven-skies/internal/pkg/domain" "github.com/neatflowcv/seven-skies/internal/pkg/repository" @@ -42,3 +43,17 @@ func (r *Repository) CreateWeather(ctx context.Context, weather *domain.Weather) return nil } + +func (r *Repository) ListWeathers(ctx context.Context, from, to time.Time) ([]*domain.Weather, error) { + weathers, err := gorm.G[*Weather](r.db).Where("target_date BETWEEN ? AND ?", from, to).Find(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list weathers: %w", err) + } + + var ret []*domain.Weather + for _, weather := range weathers { + ret = append(ret, weather.toDomain()) + } + + return ret, nil +} diff --git a/internal/pkg/repository/gorm/weather.go b/internal/pkg/repository/gorm/weather.go index a27da21..6e84927 100644 --- a/internal/pkg/repository/gorm/weather.go +++ b/internal/pkg/repository/gorm/weather.go @@ -25,3 +25,14 @@ func newModelWeather(weather *domain.Weather) *Weather { Temperature: float64(weather.Temperature().Value), } } + +func (w *Weather) toDomain() *domain.Weather { + return domain.NewWeather( + w.ID, + domain.WeatherSource(w.Source), + w.TargetDate, + w.ForecastDate, + domain.WeatherCondition(w.Condition), + domain.Temperature{Value: domain.Celsius(w.Temperature)}, + ) +} diff --git a/internal/pkg/repository/repository.go b/internal/pkg/repository/repository.go index 266945f..af0cfbb 100644 --- a/internal/pkg/repository/repository.go +++ b/internal/pkg/repository/repository.go @@ -2,10 +2,12 @@ package repository import ( "context" + "time" "github.com/neatflowcv/seven-skies/internal/pkg/domain" ) type Repository interface { CreateWeather(ctx context.Context, weather *domain.Weather) error + ListWeathers(ctx context.Context, from, to time.Time) ([]*domain.Weather, error) }