diff --git a/.drone/drone.yml b/.drone/drone.yml new file mode 100644 index 0000000..29cccad --- /dev/null +++ b/.drone/drone.yml @@ -0,0 +1,195 @@ +kind: pipeline +type: docker +name: watchtower-pr + +trigger: + event: + - pull_request + actions: + - synchronized + +environment: + GO_VERSION: 1.25 + +services: + - name: redis + image: redis:alpine + + - name: rabbitmq + image: rabbitmq:4-management + + - name: minio + image: minio/minio + commands: + - minio server /data + environment: + MINIO_ROOT_USER: "minio-root" + MINIO_ROOT_PASSWORD: "minio-root" + +steps: + - name: cache-built-go + image: drillster/drone-volume-cache + settings: + restore: true + mount: + - /usr/local/go + - /drone/src + volumes: + - name: go-targets + path: /cache + + - name: build + image: golang:1.25 + depends_on: + - cache-built-go + commands: + - go build watchtower/cmd/watchtower + volumes: + - name: go-targets + path: /cache + + - name: lint + image: golang:1.25 + depends_on: + - build + commands: + - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + - golangci-lint run ./... + volumes: + - name: go-targets + path: /cache + + - name: test + image: golang:1.25 + depends_on: + - build + commands: + - sleep 15 + - curl -v -u guest:guest -K /drone/src/tests/config/rmq/headers.conf --data-binary '@/drone/src/tests/config/rmq/definitions.json' http://rabbitmq:15672/api/definitions + - sleep 10 + - go test watchtower/tests + volumes: + - name: go-targets + path: /cache + +volumes: + - name: go-targets + temp: {} + + +# On merged pull request to main branch or created release branch +--- +kind: pipeline +type: docker +name: watchtower-merged + +trigger: + event: + - push + branch: + - main + - master + - release/* + +environment: + GO_VERSION: 1.25 + +services: + - name: redis + image: redis:alpine + + - name: rabbitmq + image: rabbitmq:4-management + + - name: minio + image: minio/minio + commands: + - minio server /data + environment: + MINIO_ROOT_USER: "minio-root" + MINIO_ROOT_PASSWORD: "minio-root" + +steps: + - name: cache-built-go + image: drillster/drone-volume-cache + settings: + restore: true + mount: + - /usr/local/go + - /drone/src + volumes: + - name: go-targets + path: /cache + + - name: build + image: golang:1.25 + depends_on: + - cache-built-go + commands: + - go build watchtower/cmd/watchtower + volumes: + - name: go-targets + path: /cache + + - name: lint + image: golang:1.25 + depends_on: + - build + commands: + - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + - golangci-lint run ./... + volumes: + - name: go-targets + path: /cache + + - name: test + image: golang:1.25 + depends_on: + - build + commands: + - sleep 15 + - curl -v -u guest:guest -K /drone/src/tests/config/rmq/headers.conf --data-binary '@/drone/src/tests/config/rmq/definitions.json' http://rabbitmq:15672/api/definitions + - sleep 10 + - go test watchtower/tests + volumes: + - name: go-targets + path: /cache + +volumes: + - name: go-targets + temp: {} + + +# Build release on new tag creating event +--- +kind: pipeline +type: docker +name: watchtower-release + +trigger: + event: + - tag + +environment: + REGISTRY_ADDRESS: ${REGISTRY_ADDRESS} + +steps: + - name: build-and-push-docker + image: plugins/docker + settings: + repo: git.sova.local:3000/sova-core/watchtower + insecure: true + dockerfile: Dockerfile + registry: git.sova.local:5000 + tags: + - ${DRONE_COMMIT_SHA:0:8} + - ${DRONE_TAG} + - latest + username: + from_secret: GITEA_USERNAME + password: + from_secret: GITEA_TOKEN + +volumes: + - name: go-targets + temp: {} diff --git a/.env b/.env index cb699df..e0d6d4a 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ +WATCHTOWER__RUN_MODE=development + WATCHTOWER__ORCHESTRATOR__SEMAPHORE_SIZE=10 WATCHTOWER__OTLP__LOGGER__LEVEL=DEBUG @@ -25,8 +27,8 @@ WATCHTOWER__TASK__QUEUE__RMQ__EXCHANGE=watchtower WATCHTOWER__TASK__QUEUE__RMQ__ROUTING_KEY=task WATCHTOWER__TASK__QUEUE__RMQ__QUEUE=watchtower-tasks -WATCHTOWER__TASK__PROCESSOR__DOCPARSER__ADDRESS=http://localhost:8012/api/v1 +WATCHTOWER__TASK__PROCESSOR__DOCPARSER__ADDRESS=http://localhost:8012 WATCHTOWER__TASK__PROCESSOR__DOCPARSER__TIMEOUT=100s -WATCHTOWER__TASK__PROCESSOR__DOCSTORAGE__ADDRESS=http://localhost:2892/api/v1 +WATCHTOWER__TASK__PROCESSOR__DOCSTORAGE__ADDRESS=http://localhost:2892 WATCHTOWER__TASK__PROCESSOR__DOCSTORAGE__TIMEOUT=100s \ No newline at end of file diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7be977e..0adc7f6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -26,11 +26,13 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: - go-version: stable + go-version: 1.25.7 + check-latest: 'false' + - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: - version: v2.6.0 + version: latest test: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 13ac78f..e820dd1 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ build: go build -v -o $(SERVICE_BIN_FILE_PATH) ./cmd/watchtower run: build - $(SERVICE_BIN_FILE_PATH) -c ./configs/config.toml + $(SERVICE_BIN_FILE_PATH) -d test: go test -race ./tests/... diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..0a8d1b7 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + "strings" + + otlp_go "github.com/breadrock1/otlp-go/otlp" + "github.com/spf13/viper" + + "watchtower/cmd/watchtower/httpserver" + "watchtower/internal/core/cloud/infrastructure/s3" + "watchtower/internal/process" + "watchtower/internal/support/task/infrastructure/docparser" + "watchtower/internal/support/task/infrastructure/docsearch" + "watchtower/internal/support/task/infrastructure/redis" + "watchtower/internal/support/task/infrastructure/rmq" +) + +type Config struct { + Otlp otlp_go.OtlpConfig `mapstructure:"otlp"` + Orchestrator process.Config `mapstructure:"orchestrator"` + Server ServerConfig `mapstructure:"server"` + Storage StorageConfig `mapstructure:"storage"` + Task TaskConfig `mapstructure:"task"` +} + +type ServerConfig struct { + Http httpserver.Config `mapstructure:"http"` +} + +type StorageConfig struct { + S3 s3.Config `mapstructure:"s3"` +} + +type TaskConfig struct { + TaskStorage TaskStorageConfig `mapstructure:"storage"` + TaskQueue TaskQueueConfig `mapstructure:"queue"` + Processor ProcessorConfig `mapstructure:"processor"` +} + +type TaskStorageConfig struct { + Redis redis.Config `mapstructure:"redis"` +} + +type TaskQueueConfig struct { + Rmq rmq.Config `mapstructure:"rmq"` +} + +type ProcessorConfig struct { + DocParser docparser.Config `mapstructure:"docparser"` + DocStorage docsearch.Config `mapstructure:"docstorage"` +} + +const ( + launchModeEnvKey = "WATCHTOWER__RUN_MODE" + defaultLaunchMode = "development" + serviceEnvPrefix = "WATCHTOWER" +) + +func InitConfig() (*Config, error) { + launchMode := os.Getenv(launchModeEnvKey) + if launchMode == "" { + launchMode = defaultLaunchMode + } + + viperInst := viper.New() + + viperInst.SetConfigName(launchMode) + viperInst.SetConfigType("toml") + + viperInst.AddConfigPath(".") + viperInst.AddConfigPath("./configs") + viperInst.AddConfigPath("../configs") + + if launchMode == defaultLaunchMode { + // Used to include config from integration tests + viperInst.AddConfigPath("../../configs") + } + + if err := viperInst.ReadInConfig(); err != nil { + //nolint + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + slog.Info("config file not found, using env vars") + } else { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + setupEnv(viperInst) + + config := &Config{} + if err := viperInst.Unmarshal(config); err != nil { + confErr := fmt.Errorf("failed while unmarshaling config: %w", err) + return config, confErr + } + + return config, nil +} + +func setupEnv(viperInst *viper.Viper) { + viperInst.AutomaticEnv() + viperInst.SetEnvPrefix(serviceEnvPrefix) + viperInst.SetEnvKeyReplacer(strings.NewReplacer(".", "__")) + + //nolint + envMappings := map[string]string{ + "orchestrator.semaphore_size": "ORCHESTRATOR__SEMAPHORE_SIZE", + "otlp.logger.level": "OTLP__LOGGER__LEVEL", + "otlp.logger.address": "OTLP__LOGGER__ADDRESS", + "otlp.logger.enable_loki": "OTLP__LOGGER__ENABLE_LOKI", + "otlp.tracer.address": "OTLP__TRACER__ADDRESS", + "otlp.tracer.enable_jaeger": "OTLP__TRACER__ENABLE_JAEGER", + "server.http.address": "SERVER__HTTP__ADDRESS", + "storage.s3.address": "STORAGE__S3__ADDRESS", + "storage.s3.access_id": "STORAGE__S3__ACCESS_ID", + "storage.s3.secret_key": "STORAGE__S3__SECRET_KEY", + "storage.s3.enable_ssl": "STORAGE__S3__ENABLE_SSL", + "storage.s3.token": "STORAGE__S3__TOKEN", + "task.storage.redis.address": "TASK__STORAGE__REDIS__ADDRESS", + "task.storage.redis.username": "TASK__STORAGE__REDIS__USERNAME", + "task.storage.redis.password": "TASK__STORAGE__REDIS__PASSWORD", + "task.storage.redis.expired": "TASK__STORAGE__REDIS__EXPIRED", + "task.queue.rmq.address": "TASK__QUEUE__RMQ__ADDRESS", + "task.queue.rmq.exchange": "TASK__QUEUE__RMQ__EXCHANGE", + "task.queue.rmq.routing_key": "TASK__QUEUE__RMQ__ROUTING_KEY", + "task.queue.rmq.queue": "TASK__QUEUE__RMQ__QUEUE", + "task.processor.docstorage.address": "TASK__PROCESSOR__DOCSTORAGE__ADDRESS", + "task.processor.docstorage.timeout": "TASK__PROCESSOR__DOCSTORAGE__TIMEOUT", + "task.processor.docparser.address": "TASK__PROCESSOR__DOCPARSER__ADDRESS", + "task.processor.docparser.timeout": "TASK__PROCESSOR__DOCPARSER__TIMEOUT", + } + + var bindErr error + for key, value := range envMappings { + bindErr = viperInst.BindEnv(key, fmt.Sprintf("%s__%s", serviceEnvPrefix, value)) + if bindErr != nil { + slog.Warn("failed to bind env var", slog.String("err", bindErr.Error())) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 34a95c2..f9af41d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,13 +2,14 @@ package cmd import ( "log" + "log/slog" "os" + "github.com/joho/godotenv" "github.com/spf13/cobra" - "watchtower/cmd/watchtower/config" ) -var serviceConfig *config.Config +var serviceConfig *Config // rootCmd represents the base command when called without any subcommands. var rootCmd = &cobra.Command{ @@ -20,17 +21,25 @@ var rootCmd = &cobra.Command{ Run: func(cmd *cobra.Command, _ []string) { var parseErr error - filePath, _ := cmd.Flags().GetString("config") - serviceConfig, parseErr = config.FromFile(filePath) + + dotEnvEnabled, err := cmd.Flags().GetBool("dotenv") + if err == nil && dotEnvEnabled { + err = godotenv.Load(".env") + if err != nil { + slog.Warn("failed to load .env file") + } + } + + serviceConfig, parseErr = InitConfig() if parseErr != nil { - log.Fatal(parseErr) + log.Fatalf("launch failed: %s", parseErr.Error()) } }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() *config.Config { +func Execute() *Config { err := rootCmd.Execute() if err != nil { os.Exit(1) @@ -40,5 +49,5 @@ func Execute() *config.Config { func init() { flags := rootCmd.Flags() - flags.StringP("config", "c", "./configs/config.toml", "Parse options from config file.") + flags.BoolP("dotenv", "d", false, "load environment vars using dotenv") } diff --git a/cmd/watchtower/config/config.go b/cmd/watchtower/config/config.go deleted file mode 100644 index 3aa127c..0000000 --- a/cmd/watchtower/config/config.go +++ /dev/null @@ -1,204 +0,0 @@ -package config - -import ( - "fmt" - "strings" - - "github.com/joho/godotenv" - "github.com/spf13/viper" - - "watchtower/cmd/watchtower/httpserver" - "watchtower/internal/core/cloud/infrastructure/s3" - "watchtower/internal/process" - "watchtower/internal/shared/telemetry" - "watchtower/internal/support/task/infrastructure/docparser" - "watchtower/internal/support/task/infrastructure/docsearch" - "watchtower/internal/support/task/infrastructure/redis" - "watchtower/internal/support/task/infrastructure/rmq" -) - -type Config struct { - Orchestrator process.Config `mapstructure:"orchestrator"` - Otlp telemetry.OtlpConfig `mapstructure:"otlp"` - Server ServerConfig `mapstructure:"server"` - Storage StorageConfig `mapstructure:"storage"` - Task TaskConfig `mapstructure:"task"` -} - -type ServerConfig struct { - Http httpserver.Config `mapstructure:"http"` -} - -type StorageConfig struct { - S3 s3.Config `mapstructure:"s3"` -} - -type TaskConfig struct { - TaskStorage TaskStorageConfig `mapstructure:"storage"` - TaskQueue TaskQueueConfig `mapstructure:"queue"` - Processor ProcessorConfig `mapstructure:"processor"` -} - -type TaskStorageConfig struct { - Redis redis.Config `mapstructure:"redis"` -} - -type TaskQueueConfig struct { - Rmq rmq.Config `mapstructure:"rmq"` -} - -type ProcessorConfig struct { - DocParser docparser.Config `mapstructure:"docparser"` - DocStorage docsearch.Config `mapstructure:"docstorage"` -} - -func FromFile(filePath string) (*Config, error) { - _ = godotenv.Load() - - config := &Config{} - - viperInstance := viper.New() - viperInstance.SetConfigFile(filePath) - viperInstance.SetConfigType("toml") - - viperInstance.AutomaticEnv() - viperInstance.SetEnvPrefix("watchtower") - viperInstance.SetEnvKeyReplacer(strings.NewReplacer(".", "__")) - - // Orchestrator config - bindErr := viperInstance.BindEnv("orchestrator.semaphore_size", "WATCHTOWER__ORCHESTRATOR__SEMAPHORE_SIZE") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Otlp config - bindErr = viperInstance.BindEnv("otlp.logger.level", "WATCHTOWER__OTLP__LOGGER__LEVEL") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("otlp.logger.address", "WATCHTOWER__OTLP__LOGGER__ADDRESS") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("otlp.logger.enable_loki", "WATCHTOWER__OTLP__LOGGER__ENABLE_LOKI") - - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("otlp.tracer.address", "WATCHTOWER__OTLP__TRACER__ADDRESS") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("otlp.tracer.enable_jaeger", "WATCHTOWER__OTLP__TRACER__ENABLE_JAEGER") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Server config - bindErr = viperInstance.BindEnv("server.http.address", "WATCHTOWER__SERVER__HTTP__ADDRESS") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Storage s3 config - bindErr = viperInstance.BindEnv("storage.s3.address", "WATCHTOWER__STORAGE__S3__ADDRESS") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("storage.s3.access_id", "WATCHTOWER__STORAGE__S3__ACCESS_ID") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("storage.s3.secret_key", "WATCHTOWER__STORAGE__S3__SECRET_KEY") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("storage.s3.enable_ssl", "WATCHTOWER__STORAGE__S3__ENABLE_SSL") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("storage.s3.token", "WATCHTOWER__STORAGE__S3__TOKEN") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Cache redis config - bindErr = viperInstance.BindEnv("task.storage.redis.address", "WATCHTOWER__TASK__STORAGE__REDIS__ADDRESS") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("task.storage.redis.username", "WATCHTOWER__TASK__STORAGE__REDIS__USERNAME") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("task.storage.redis.password", "WATCHTOWER__TASK__STORAGE__REDIS__PASSWORD") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("task.storage.redis.expired", "WATCHTOWER__TASK__STORAGE__REDIS__EXPIRED") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Queue emq config - bindErr = viperInstance.BindEnv("task.queue.rmq.address", "WATCHTOWER__TASK__QUEUE__RMQ__ADDRESS") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("task.queue.rmq.exchange", "WATCHTOWER__TASK__QUEUE__RMQ__EXCHANGE") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("task.queue.rmq.routing_key", "WATCHTOWER__TASK__QUEUE__RMQ__ROUTING_KEY") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv("task.queue.rmq.queue", "WATCHTOWER__TASK__QUEUE__RMQ__QUEUE") - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Docstorage config - bindErr = viperInstance.BindEnv( - "task.processor.docstorage.address", - "WATCHTOWER__TASK__PROCESSOR__DOCSTORAGE__ADDRESS", - ) - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv( - "task.processor.docstorage.timeout", - "WATCHTOWER__TASK__PROCESSOR__DOCSTORAGE__TIMEOUT", - ) - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - // Docparser config - bindErr = viperInstance.BindEnv( - "task.processor.docparser.address", - "WATCHTOWER__TASK__PROCESSOR__DOCPARSER__ADDRESS", - ) - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - bindErr = viperInstance.BindEnv( - "task.processor.docparser.timeout", - "WATCHTOWER__TASK__PROCESSOR__DOCPARSER__TIMEOUT", - ) - if bindErr != nil { - return nil, fmt.Errorf("failed to bine env varialbe: %w", bindErr) - } - - if err := viperInstance.ReadInConfig(); err != nil { - confErr := fmt.Errorf("failed while reading config file %s: %w", filePath, err) - return config, confErr - } - - if err := viperInstance.Unmarshal(config); err != nil { - confErr := fmt.Errorf("failed while unmarshaling config file %s: %w", filePath, err) - return config, confErr - } - - return config, nil -} diff --git a/cmd/watchtower/httpserver/form/form.go b/cmd/watchtower/httpserver/form/form.go index 1ef2497..a39fc45 100644 --- a/cmd/watchtower/httpserver/form/form.go +++ b/cmd/watchtower/httpserver/form/form.go @@ -1,27 +1,5 @@ package form -func CreateStatusResponse(msg string) *ResponseForm { - return &ResponseForm{Status: 200, Message: msg} -} - -// ResponseForm example -type ResponseForm struct { - Status int `json:"status" example:"200"` - Message string `json:"message" example:"Done"` -} - -// BadRequestForm example -type BadRequestForm struct { - Status int `json:"status" example:"400"` - Message string `json:"message" example:"Bad Request message"` -} - -// ServerErrorForm example -type ServerErrorForm struct { - Status int `json:"status" example:"503"` - Message string `json:"message" example:"Server Error message"` -} - // AddDirectoryToWatcherForm example type AddDirectoryToWatcherForm struct { BucketName string `json:"bucket" example:"test-folder"` @@ -59,6 +37,8 @@ type ShareFileForm struct { // GetFilesForm example type GetFilesForm struct { DirectoryName string `json:"directory" example:"test-folder/"` + Limit int32 `json:"limit" example:"10"` + Offset int32 `json:"offset" example:"0"` } // GetFileAttributesForm example @@ -68,6 +48,12 @@ type GetFileAttributesForm struct { // CopyFileForm example type CopyFileForm struct { - SrcPath string `json:"src_path" example:"old-test-document.docx"` - DstPath string `json:"dst_path" example:"test-document.docx"` + SrcPath string `json:"src_path" example:"old-test-document.docx"` + DstPath string `json:"dst_path" example:"test-document.docx"` + WithRemove bool `json:"with_remove" example:"true"` +} + +// FolderForm example +type FolderForm struct { + Prefix string `json:"prefix" example:"test-folder"` } diff --git a/cmd/watchtower/httpserver/form/result.go b/cmd/watchtower/httpserver/form/result.go new file mode 100644 index 0000000..a8408b3 --- /dev/null +++ b/cmd/watchtower/httpserver/form/result.go @@ -0,0 +1,50 @@ +package form + +// Success example +type Success struct { + Status int `json:"status" example:"200"` + Message string `json:"message" example:"Done"` +} + +func SuccessResponse(msg string) Success { + return Success{ + Status: 200, + Message: msg, + } +} + +// BadRequestError example +type BadRequestError struct { + Status int `json:"status" example:"400"` + Message string `json:"message" example:"Bad Request message"` +} + +// NotAcceptableError example +type NotAcceptableError struct { + Status int `json:"status" example:"406"` + Message string `json:"message" example:"Not acceptable request"` +} + +// AuthError example +type AuthError struct { + Status int `json:"status" example:"400"` + Message string `json:"message" example:"auth error"` +} + +// NotFoundError example +type NotFoundError struct { + Status int `json:"status" example:"404"` + Message string `json:"message" example:"Not found"` +} + +// InternalServerError example +type InternalServerError struct { + Status int `json:"status" example:"500"` + Message string `json:"message" example:"Internal server error message"` +} + +// ServerUnavailableError example +type ServerUnavailableError struct { + Status int `json:"status" example:"503"` + Message string `json:"message" example:"Server unavailable error message"` +} diff --git a/cmd/watchtower/httpserver/helper.go b/cmd/watchtower/httpserver/helper.go new file mode 100644 index 0000000..e71035e --- /dev/null +++ b/cmd/watchtower/httpserver/helper.go @@ -0,0 +1,87 @@ +package httpserver + +import ( + "fmt" + "log/slog" + "mime/multipart" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +func ExtractBucketParameter(eCtx *fiber.Ctx) (string, error) { + bucket := eCtx.Params("bucket") + if bucket == "" { + err := fmt.Errorf("bucket parameter is required") + return "", err + } + + return bucket, nil +} + +func ExtractTaskIDParameter(eCtx *fiber.Ctx) (uuid.UUID, error) { + taskIDParam := eCtx.Params("task_id") + if taskIDParam == "" { + err := fmt.Errorf("bucket parameter is required") + return uuid.Nil, err + } + + taskID, err := uuid.Parse(taskIDParam) + if err != nil { + return taskID, err + } + + return taskID, nil +} + +func ExtractTaskStatusParameter(eCtx *fiber.Ctx) (int, error) { + statusParam := eCtx.Query("status") + status, err := strconv.Atoi(statusParam) + if err != nil { + err = fmt.Errorf("unknown status parameter: %w", err) + return -1, err + } + + return status, nil +} + +func ExtractFileNameParameter(eCtx *fiber.Ctx) (string, error) { + fileNameQuery := eCtx.Query("file_name") + if fileNameQuery == "" { + err := fmt.Errorf("bucket parameter is required") + return "", err + } + + return fileNameQuery, nil +} + +func ExtractMultipartForm(eCtx *fiber.Ctx) (*multipart.Form, error) { + multipartForm, err := eCtx.MultipartForm() + if err != nil { + return nil, err + } + + if multipartForm.File["files"] == nil { + err = fmt.Errorf("there are no files into multipart form") + return nil, err + } + + return multipartForm, nil +} + +func ExtractExpiredDatetime(eCtx *fiber.Ctx) (*time.Time, error) { + expired := eCtx.Query("expired") + if expired == "" { + slog.Debug("expired parameter has not been set") + return &time.Time{}, nil + } + + timeVal, err := time.Parse(time.RFC3339, expired) + if err != nil { + return nil, fmt.Errorf("failed to parse expired datetime: %w", err) + } + + return &timeVal, nil +} diff --git a/cmd/watchtower/httpserver/httpserver.go b/cmd/watchtower/httpserver/httpserver.go index b163810..fecdd22 100644 --- a/cmd/watchtower/httpserver/httpserver.go +++ b/cmd/watchtower/httpserver/httpserver.go @@ -1,23 +1,24 @@ package httpserver import ( - "context" "fmt" - - "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho" + "log/slog" + + "github.com/breadrock1/otlp-go/otlp" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/monitor" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/swagger" + "github.com/prometheus/client_golang/prometheus/promhttp" "go.opentelemetry.io/otel/trace" - "github.com/labstack/echo-contrib/echoprometheus" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - - "watchtower/cmd/watchtower/httpserver/mw" + _ "watchtower/docs" "watchtower/internal/process" - "watchtower/internal/shared/telemetry" - - docs "watchtower/docs" + "watchtower/internal/shared/kernel" - echoSwagger "github.com/swaggo/echo-swagger" + otlppfiber "github.com/breadrock1/otlp-go/pkg/fiber" ) // Server @@ -46,69 +47,71 @@ type Server struct { tracer trace.Tracer state *process.Orchestrator - server *echo.Echo + Server *fiber.App } -func SetupServer( - config telemetry.OtlpConfig, - state *process.Orchestrator, - tracer trace.Tracer, -) *Server { +func SetupServer(otlpConfig otlp_go.OtlpConfig, state *process.Orchestrator) *Server { + tracer, err := otlp_go.InitTracer(otlpConfig.Tracer) + if err != nil { + slog.Warn("failed to init tracer", slog.String("err", err.Error())) + } + serverApp := &Server{ tracer: tracer, state: state, } - serverApp.server = echo.New() + serverApp.Server = fiber.New( + fiber.Config{ + DisableStartupMessage: true, + }, + ) + + serverApp.initMiddlewares(otlpConfig) - serverApp.server.Use(middleware.CORS()) - serverApp.server.Use(middleware.Recover()) + serverApp.Server.Get("/", serverApp.Home) + serverApp.Server.Get("/monitor", monitor.New()) + serverApp.Server.Get("/processing/metrics", adaptor.HTTPHandler(promhttp.Handler())) - serverApp.initMeterMW() - serverApp.initTracerMW() - serverApp.initLoggerMW(config.Logger) + api := serverApp.Server.Group("/api") - _ = serverApp.CreateSystemGroup() - _ = serverApp.CreateTasksGroup() - _ = serverApp.CreateStorageBucketsGroup() - _ = serverApp.CreateStorageObjectsGroup() + api.Get("/swagger/*", swagger.HandlerDefault) - docs.SwaggerInfo.BasePath = "/api/v1" - serverApp.server.GET("/api/swagger/*", echoSwagger.WrapHandler) + v1Api := api.Group("/v1") + serverApp.CreateSystemGroup(v1Api) + serverApp.CreateTasksGroup(v1Api) + serverApp.CreateStorageBucketsGroup(v1Api) + serverApp.CreateStorageObjectsGroup(v1Api) return serverApp } -func (s *Server) Start(_ context.Context, config Config) error { - if err := s.server.Start(config.Address); err != nil { +func (s *Server) Start(config Config) error { + slog.Info("starting http server", slog.String("address", config.Address)) + if err := s.Server.Listen(config.Address); err != nil { return fmt.Errorf("failed to start server: %w", err) } return nil } -func (s *Server) Shutdown(ctx context.Context) error { - return s.server.Shutdown(ctx) +func (s *Server) Shutdown(ctx kernel.Ctx) error { + slog.Info("http server shutting down") + return s.Server.ShutdownWithContext(ctx) } -func (s *Server) initMeterMW() { - s.server.Use(echoprometheus.NewMiddleware(telemetry.AppName)) - s.server.GET("/metrics", echoprometheus.NewHandler()) -} +func (s *Server) initMiddlewares(otlpConfig otlp_go.OtlpConfig) { + s.Server.Use(cors.New(cors.Config{})) + s.Server.Use(recover.New()) -func (s *Server) initLoggerMW(logConfig telemetry.LoggerConfig) { - if logConfig.EnableLoki { - lokiLog := telemetry.InitLokiLogger(logConfig) - s.server.Use(mw.CreateLokiLoggerMW(&lokiLog)) - } else { - s.server.Use(mw.InitLocalLogger(logConfig)) - } -} + s.Server.Use(otlppfiber.PrometheusMeterMiddleware(s.Server)) + s.Server.Use(otlppfiber.OtlpJaegerTracerMiddleware()) -func (s *Server) initTracerMW() { - s.server.Use(otelecho.Middleware( - telemetry.AppName, - otelecho.WithPropagators(telemetry.TracePropagator), - otelecho.WithSkipper(mw.TracerSkipper), - )) + logger := otlp_go.InitLocalLogger(otlpConfig.Logger) + slog.SetDefault(logger) + + s.Server.Use(otlppfiber.StdoutLoggerMiddleware(otlpConfig.Logger)) + if otlpConfig.Logger.EnableLoki { + s.Server.Use(otlppfiber.RemoteLokiLoggerMiddleware(otlpConfig.Logger)) + } } diff --git a/cmd/watchtower/httpserver/mw/logger.go b/cmd/watchtower/httpserver/mw/logger.go deleted file mode 100644 index e9fae84..0000000 --- a/cmd/watchtower/httpserver/mw/logger.go +++ /dev/null @@ -1,81 +0,0 @@ -package mw - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "slices" - "strings" - "time" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "watchtower/internal/shared/telemetry" -) - -func InitLocalLogger(config telemetry.LoggerConfig) echo.MiddlewareFunc { - logConfig := middleware.LoggerConfig{ - Skipper: func(c echo.Context) bool { - uri := c.Path() - return strings.Contains(uri, "swagger") - }, - CustomTimeFormat: "2006/01/02 15:04:05", - Format: fmt.Sprintf( - "%s %s http-response={%s %s %s %s %s}\n", - "${time_custom}", - config.Level, - "method=${method}", - "uri=${path}", - "latency=${latency}", - "status=${status}", - "error=\"${error}\"", - ), - } - - return middleware.LoggerWithConfig(logConfig) -} - -func CreateLokiLoggerMW(sll *telemetry.SlogLokiLogger) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(eCtx echo.Context) error { - if slices.Contains(sll.FilterURI, eCtx.Path()) { - return next(eCtx) - } - - start := time.Now() - - err := next(eCtx) - if err != nil { - eCtx.Error(err) - } - - latency := time.Since(start) - - logMessage := map[string]interface{}{ - "message": eCtx.Response().Status, - "latency": latency.String(), - "status": eCtx.Response().Status, - "method": eCtx.Request().Method, - "uri": eCtx.Path(), - "client_ip": eCtx.RealIP(), - "user_agent": eCtx.Request().UserAgent(), - } - jsonMessage, _ := json.Marshal(logMessage) - - var logLevel slog.Level - statusCategory := eCtx.Response().Status / 100 - if statusCategory < 3 && statusCategory >= 2 { - logLevel = slog.LevelInfo - } else { - logLevel = slog.LevelError - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - sll.Client.Log(ctx, logLevel, string(jsonMessage)) - defer cancel() - - return err - } - } -} diff --git a/cmd/watchtower/httpserver/mw/tracer.go b/cmd/watchtower/httpserver/mw/tracer.go deleted file mode 100644 index 5c8d13e..0000000 --- a/cmd/watchtower/httpserver/mw/tracer.go +++ /dev/null @@ -1,28 +0,0 @@ -package mw - -import ( - "net/http" - "strings" - - "github.com/labstack/echo/v4" -) - -var ( - excludedPaths = []string{ - "/health", - "/metrics", - "/favicon.ico", - "/static/", - "/api/v1/swagger", - } -) - -func TracerSkipper(eCtx echo.Context) bool { - for _, excluded := range excludedPaths { - if strings.HasPrefix(eCtx.Path(), excluded) { - return true - } - } - - return eCtx.Request().Method == http.MethodOptions -} diff --git a/cmd/watchtower/httpserver/routes_bucket.go b/cmd/watchtower/httpserver/routes_bucket.go index 3f1d842..230caac 100644 --- a/cmd/watchtower/httpserver/routes_bucket.go +++ b/cmd/watchtower/httpserver/routes_bucket.go @@ -2,21 +2,19 @@ package httpserver import ( "encoding/json" - "net/http" - "github.com/labstack/echo/v4" + "github.com/gofiber/fiber/v2" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "watchtower/cmd/watchtower/httpserver/form" ) -func (s *Server) CreateStorageBucketsGroup() error { - group := s.server.Group("/api/v1/cloud") - - group.GET("/buckets", s.GetBuckets) - group.PUT("/bucket", s.CreateBucket) - group.DELETE("/:bucket", s.RemoveBucket) - - return nil +func (s *Server) CreateStorageBucketsGroup(group fiber.Router) { + group.Get("/cloud/buckets", s.GetBuckets) + group.Put("/cloud/bucket", s.CreateBucket) + group.Delete("/cloud/:bucket", s.RemoveBucket) } // GetBuckets @@ -25,14 +23,21 @@ func (s *Server) CreateStorageBucketsGroup() error { // @ID get-buckets // @Tags buckets // @Produce json -// @Success 200 {array} string "Ok" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} []form.BucketSchema "Loaded buckets info" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/buckets [get] -func (s *Server) GetBuckets(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - buckets, err := s.state.GetObjectStorage().GetAllBuckets(ctx) +func (s *Server) GetBuckets(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) + + objStorage := s.state.GetObjectStorage() + buckets, err := objStorage.GetAllBuckets(ctx) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } bucketsDto := make([]form.BucketSchema, len(buckets)) @@ -40,7 +45,7 @@ func (s *Server) GetBuckets(eCtx echo.Context) error { bucketsDto[index] = form.BucketFromDomain(bucket) } - return eCtx.JSON(200, buckets) + return eCtx.Status(fiber.StatusOK).JSON(bucketsDto) } // CreateBucket @@ -51,26 +56,48 @@ func (s *Server) GetBuckets(eCtx echo.Context) error { // @Accept json // @Produce json // @Param jsonQuery body form.CreateBucketForm true "Bucket name to create" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/bucket [put] -func (s *Server) CreateBucket(eCtx echo.Context) error { - ctx := eCtx.Request().Context() +func (s *Server) CreateBucket(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - jsonForm := &form.CreateBucketForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - err := decoder.Decode(jsonForm) + var jsonForm form.CreateBucketForm + err := json.Unmarshal(eCtx.Body(), &jsonForm) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + objStorage := s.state.GetObjectStorage() + exists, err := objStorage.IsBucketExists(ctx, jsonForm.BucketName) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if exists { + span.SetStatus(codes.Error, "bucket already exists") + span.RecordError(err) + // TODO: Temporary solution. Need to return 409 http error + // return eCtx.Status(fiber.StatusConflict).SendString("bucket already exists") + return eCtx.Status(fiber.StatusOK).SendString("bucket already exists") } - err = s.state.GetObjectStorage().CreateBucket(ctx, jsonForm.BucketName) + err = objStorage.CreateBucket(ctx, jsonForm.BucketName) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(201, form.CreateStatusResponse("Ok")) + return eCtx.Status(fiber.StatusCreated).SendString("Ok") } // RemoveBucket @@ -80,18 +107,48 @@ func (s *Server) CreateBucket(eCtx echo.Context) error { // @Tags buckets // @Produce json // @Param bucket path string true "Bucket name to remove" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket} [delete] -func (s *Server) RemoveBucket(eCtx echo.Context) error { - ctx := eCtx.Request().Context() +func (s *Server) RemoveBucket(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) + + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + span.SetAttributes(attribute.String("bucket", bucket)) + + objStorage := s.state.GetObjectStorage() + exists, err := objStorage.IsBucketExists(ctx, bucket) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if !exists { + span.SetStatus(codes.Error, "buket does not exist") + span.RecordError(err) + // TODO: Temporary solution. Need to return 409 http error + // return eCtx.Status(fiber.StatusConflict).SendString("bucket already exists") + return eCtx.Status(fiber.StatusNotFound).SendString("bucket already exists") + } - bucket := eCtx.Param("bucket") - err := s.state.GetObjectStorage().DeleteBucket(ctx, bucket) + err = objStorage.DeleteBucket(ctx, bucket) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(200, form.CreateStatusResponse("Ok")) + return eCtx.Status(fiber.StatusOK).SendString("Ok") } diff --git a/cmd/watchtower/httpserver/routes_object.go b/cmd/watchtower/httpserver/routes_object.go index eff8cf2..6c4cecf 100644 --- a/cmd/watchtower/httpserver/routes_object.go +++ b/cmd/watchtower/httpserver/routes_object.go @@ -6,103 +6,162 @@ import ( "fmt" "log/slog" "net/http" + "path" "time" + "github.com/gofiber/fiber/v2" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "watchtower/cmd/watchtower/httpserver/form" "watchtower/internal/core/cloud/domain" - - "github.com/labstack/echo/v4" ) -func (s *Server) CreateStorageObjectsGroup() error { - group := s.server.Group("/api/v1/cloud") +const FolderFileKeeper = ".keeper" + +func (s *Server) CreateStorageObjectsGroup(group fiber.Router) { + group.Post("/cloud/:bucket/files", s.GetFiles) + group.Patch("/cloud/:bucket/file", s.CopyFile) + group.Put("/cloud/:bucket/file/upload", s.UploadFile) + group.Post("/cloud/:bucket/file/download", s.DownloadFile) + group.Post("/cloud/:bucket/folder", s.CreateFolder) + group.Delete("/cloud/:bucket/folder", s.DeleteFolder) + group.Delete("/cloud/:bucket/file", s.RemoveFile2) + group.Delete("/cloud/:bucket/file/remove", s.RemoveFile) + group.Post("/cloud/:bucket/file/attributes", s.GetFileInfo) + group.Post("/cloud/:bucket/file/share", s.ShareFile) +} - group.POST("/:bucket/files", s.GetFiles) - group.POST("/:bucket/file/copy", s.CopyFile) - group.POST("/:bucket/file/move", s.MoveFile) - group.PUT("/:bucket/file/upload", s.UploadFile) - group.POST("/:bucket/file/download", s.DownloadFile) - group.DELETE("/:bucket/file", s.RemoveFile2) - group.DELETE("/:bucket/file/remove", s.RemoveFile) - group.POST("/:bucket/file/attributes", s.GetFileInfo) +// CreateFolder +// @Summary Create empty folder into cloud storage +// @Description Create empty folder into cloud storage +// @ID create-folder +// @Tags files +// @Accept application/json +// @Produce json +// @Param bucket path string true "Bucket name to create folder" +// @Param jsonQuery body form.FolderForm true "Params to create folder" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" +// @Router /api/v1/cloud/{bucket}/folder [post] +func (s *Server) CreateFolder(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) + + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } - group.POST("/:bucket/file/share", s.ShareFile) + span.SetAttributes(attribute.String("bucket", bucket)) - return nil -} + objectStorage := s.state.GetObjectStorage() + exist, err := objectStorage.IsBucketExists(ctx, bucket) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusBadRequest).SendString(err.Error()) + } -// CopyFile -// @Summary Copy file to another location into bucket -// @Description Copy file to another location into bucket -// @ID copy-file -// @Tags files -// @Accept json -// @Produce json -// @Param bucket path string true "Bucket name of src file" -// @Param jsonQuery body form.CopyFileForm true "Params to copy file" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" -// @Router /api/v1/cloud/{bucket}/file/copy [post] -func (s *Server) CopyFile(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") + if !exist { + err = fmt.Errorf("specified bucket %s does not exist", bucket) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusNotFound).SendString(err.Error()) + } - jsonForm := &form.CopyFileForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - err := decoder.Decode(jsonForm) + var jsonForm form.FolderForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - params := &domain.CopyObjectParams{ - SourcePath: jsonForm.SrcPath, - DestinationPath: jsonForm.DstPath, + keepFilePath := path.Join(jsonForm.Prefix, FolderFileKeeper) + params := &domain.UploadObjectParams{ + FilePath: keepFilePath, + FileData: bytes.NewBufferString(""), + Expired: nil, } - err = s.state.GetObjectStorage().CopyObject(ctx, bucket, params) + _, err = objectStorage.StoreObject(ctx, bucket, params) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(200, form.CreateStatusResponse("Ok")) + return eCtx.Status(fiber.StatusCreated).SendString("Ok") } -// MoveFile -// @Summary Move file to another location into bucket -// @Description Move file to another location into bucket -// @ID move-file +// DeleteFolder +// @Summary Delete folder into cloud storage +// @Description Delete empty folder into cloud storage +// @ID delete-folder // @Tags files -// @Accept json -// @Produce json -// @Param bucket path string true "Bucket name of src file" -// @Param jsonQuery body form.CopyFileForm true "Params to move file" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" -// @Router /api/v1/cloud/{bucket}/file/move [post] -func (s *Server) MoveFile(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") +// @Accept application/json +// @Produce json +// @Param bucket path string true "Bucket name to delete folder" +// @Param jsonQuery body form.FolderForm true "Params to delete folder" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" +// @Router /api/v1/cloud/{bucket}/folder [delete] +func (s *Server) DeleteFolder(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) + + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + span.SetAttributes(attribute.String("bucket", bucket)) - jsonForm := &form.CopyFileForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - err := decoder.Decode(jsonForm) + objectStorage := s.state.GetObjectStorage() + exist, err := objectStorage.IsBucketExists(ctx, bucket) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusBadRequest).SendString(err.Error()) } - params := &domain.CopyObjectParams{ - SourcePath: jsonForm.SrcPath, - DestinationPath: jsonForm.DstPath, + if !exist { + err = fmt.Errorf("specified bucket %s does not exist", bucket) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusNotFound).SendString(err.Error()) + } + + var jsonForm form.FolderForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - err = s.state.GetObjectStorage().MoveObject(ctx, bucket, params) + err = objectStorage.DeleteObjects(ctx, bucket, jsonForm.Prefix) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(200, form.CreateStatusResponse("Ok")) + return eCtx.Status(fiber.StatusOK).SendString("Ok") } // UploadFile @@ -115,51 +174,77 @@ func (s *Server) MoveFile(eCtx echo.Context) error { // @Param bucket path string true "Bucket name to upload files" // @Param files formData file true "Files multipart form" // @Param expired query string false "File datetime expired like 2025-01-01T12:01:01Z" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/file/upload [put] -func (s *Server) UploadFile(eCtx echo.Context) error { - ctx := eCtx.Request().Context() +func (s *Server) UploadFile(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() - var fileData bytes.Buffer + span := trace.SpanFromContext(ctx) - multipartForm, err := eCtx.MultipartForm() + bucket, err := ExtractBucketParameter(eCtx) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - bucket := eCtx.Param("bucket") - exist, err := s.state.GetObjectStorage().IsBucketExists(eCtx.Request().Context(), bucket) - if err != nil || !exist { - retErr := fmt.Errorf("specified bucket %s does not exist", bucket) - return echo.NewHTTPError(http.StatusBadRequest, retErr.Error()) + span.SetAttributes(attribute.String("bucket", bucket)) + + objectStorage := s.state.GetObjectStorage() + exist, err := objectStorage.IsBucketExists(ctx, bucket) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusBadRequest).SendString(err.Error()) } - if multipartForm.File["files"] == nil { - err = fmt.Errorf("there are no files into multipart form") - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + if !exist { + err = fmt.Errorf("specified bucket %s does not exist", bucket) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusNotFound).SendString(err.Error()) } - expired := eCtx.QueryParam("expired") - timeVal, timeParseErr := time.Parse(time.RFC3339, expired) - if timeParseErr != nil { - slog.Warn("failed to parse expired time param", - slog.String("err", timeParseErr.Error()), - ) + multipartForm, err := ExtractMultipartForm(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } + expiredDatetime, err := ExtractExpiredDatetime(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + var fileData bytes.Buffer uploadedFiles := make([]form.TaskSchema, len(multipartForm.File["files"])) for index, fileForm := range multipartForm.File["files"] { fileName := fileForm.Filename fileHandler, err := fileForm.Open() if err != nil { - slog.Error("failed to open file form", slog.String("err", err.Error())) + err = fmt.Errorf("failed to open file form: %w", err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + + slog.Error("multipart error", + slog.String("file", fileName), + slog.String("err", err.Error()), + ) continue } defer func() { if err := fileHandler.Close(); err != nil { - slog.Error("failed to close file handler", + err = fmt.Errorf("failed to close file handler: %w", err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + slog.Error("multipart error", slog.String("file", fileName), slog.String("err", err.Error()), ) @@ -170,7 +255,10 @@ func (s *Server) UploadFile(eCtx echo.Context) error { fileData.Reset() _, err = fileData.ReadFrom(fileHandler) if err != nil { - slog.Error("failed to read file form", + err = fmt.Errorf("failed to read file form: %w", err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + slog.Error("multipart error", slog.String("file", fileName), slog.String("err", err.Error()), ) @@ -180,12 +268,15 @@ func (s *Server) UploadFile(eCtx echo.Context) error { params := &domain.UploadObjectParams{ FilePath: fileName, FileData: &fileData, - Expired: &timeVal, + Expired: expiredDatetime, } task, err := s.state.UploadFile(ctx, bucket, params) if err != nil { - slog.Error("failed to upload file to cloud", + err = fmt.Errorf("failed to upload file form: %w", err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + slog.Error("multipart error", slog.String("file", fileName), slog.String("err", err.Error()), ) @@ -195,7 +286,7 @@ func (s *Server) UploadFile(eCtx echo.Context) error { uploadedFiles[index] = form.TaskFromDomain(*task) } - return eCtx.JSON(200, uploadedFiles) + return eCtx.Status(fiber.StatusOK).JSON(uploadedFiles) } // DownloadFile @@ -207,27 +298,44 @@ func (s *Server) UploadFile(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket name to download file" // @Param jsonQuery body form.DownloadFileForm true "Parameters to download file" -// @Success 200 {file} io.Writer "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {file} io.Writer "Returned file bytes" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/file/download [post] -func (s *Server) DownloadFile(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") +func (s *Server) DownloadFile(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - jsonForm := &form.DownloadFileForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - if err := decoder.Decode(jsonForm); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - fileData, err := s.state.GetObjectStorage().GetObjectData(ctx, bucket, jsonForm.FileName) + span.SetAttributes(attribute.String("bucket", bucket)) + + var jsonForm form.DownloadFileForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + objectStorage := s.state.GetObjectStorage() + fileData, err := objectStorage.GetObjectData(ctx, bucket, jsonForm.FileName) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } defer fileData.Reset() - return eCtx.Blob(200, echo.MIMEMultipartForm, fileData.Bytes()) + return eCtx.Send(fileData.Bytes()) } // RemoveFile @@ -238,25 +346,42 @@ func (s *Server) DownloadFile(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket name to remove file" // @Param jsonQuery body form.RemoveFileForm true "Parameters to remove file" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/file/remove [delete] -func (s *Server) RemoveFile(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") +func (s *Server) RemoveFile(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - jsonForm := &form.RemoveFileForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - if err := decoder.Decode(jsonForm); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + span.SetAttributes(attribute.String("bucket", bucket)) + + var jsonForm form.RemoveFileForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - if err := s.state.GetObjectStorage().DeleteObject(ctx, bucket, jsonForm.FileName); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + objectStorage := s.state.GetObjectStorage() + if err = objectStorage.DeleteObject(ctx, bucket, jsonForm.FileName); err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(200, form.CreateStatusResponse("Ok")) + return eCtx.Status(fiber.StatusOK).SendString("Ok") } // RemoveFile2 @@ -267,19 +392,118 @@ func (s *Server) RemoveFile(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket name to remove file" // @Param file_name query string true "Parameters to remove file" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/file [delete] -func (s *Server) RemoveFile2(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") - fileName := eCtx.QueryParam("file_name") - if err := s.state.GetObjectStorage().DeleteObject(ctx, bucket, fileName); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) +func (s *Server) RemoveFile2(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) + + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + span.SetAttributes(attribute.String("bucket", bucket)) + + fileName, err := ExtractFileNameParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + span.SetAttributes(attribute.String("file_name", fileName)) + + objectStorage := s.state.GetObjectStorage() + if err = objectStorage.DeleteObject(ctx, bucket, fileName); err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return eCtx.Status(fiber.StatusOK).SendString("Ok") +} + +// CopyFile +// @Summary Copy file to another location into bucket +// @Description Copy file to another location into bucket +// @ID copy-file +// @Tags files +// @Accept json +// @Produce json +// @Param bucket path string true "Bucket name of src file" +// @Param jsonQuery body form.CopyFileForm true "Params to copy file" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket or file not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" +// @Router /api/v1/cloud/{bucket}/file [patch] +func (s *Server) CopyFile(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) + + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + objectStorage := s.state.GetObjectStorage() + exist, err := objectStorage.IsBucketExists(ctx, bucket) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusBadRequest).SendString(err.Error()) + } + + if !exist { + err = fmt.Errorf("specified bucket %s does not exist", bucket) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(http.StatusNotFound).SendString(err.Error()) + } + + var jsonForm form.CopyFileForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + params := &domain.CopyObjectParams{ + SourcePath: jsonForm.SrcPath, + DestinationPath: jsonForm.DstPath, } - return eCtx.JSON(200, form.CreateStatusResponse("Ok")) + err = objectStorage.CopyObject(ctx, bucket, params) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if jsonForm.WithRemove { + err = objectStorage.DeleteObject(ctx, bucket, params.SourcePath) + if err != nil { + err = fmt.Errorf("failed to delete object: %w", err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + } + + return eCtx.Status(fiber.StatusOK).SendString("Ok") } // GetFiles @@ -291,28 +515,46 @@ func (s *Server) RemoveFile2(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket name to get list files" // @Param jsonQuery body form.GetFilesForm true "Parameters to get list files" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/files [post] -func (s *Server) GetFiles(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") +func (s *Server) GetFiles(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - jsonForm := &form.GetFilesForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - err := decoder.Decode(jsonForm) + bucket, err := ExtractBucketParameter(eCtx) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + span.SetAttributes(attribute.String("bucket", bucket)) + + var jsonForm form.GetFilesForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } params := &domain.GetObjectsParams{ PrefixPath: jsonForm.DirectoryName, + Limit: jsonForm.Limit, + Offset: jsonForm.Offset, } - listObjects, err := s.state.GetObjectStorage().GetBucketObjects(ctx, bucket, params) + objectStorage := s.state.GetObjectStorage() + listObjects, err := objectStorage.LoadBucketObjects(ctx, bucket, params) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } objectsDto := make([]form.ObjectSchema, len(listObjects)) @@ -320,7 +562,7 @@ func (s *Server) GetFiles(eCtx echo.Context) error { objectsDto[index] = form.ObjectFromDomain(object) } - return eCtx.JSON(200, objectsDto) + return eCtx.Status(fiber.StatusOK).JSON(objectsDto) } // GetFileInfo @@ -332,27 +574,44 @@ func (s *Server) GetFiles(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket name to get list files" // @Param jsonQuery body form.GetFileAttributesForm true "Parameters to get list files" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "Ok" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket or Object not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/file/attributes [post] -func (s *Server) GetFileInfo(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") +func (s *Server) GetFileInfo(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() - jsonForm := &form.GetFileAttributesForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - err := decoder.Decode(jsonForm) + span := trace.SpanFromContext(ctx) + + bucket, err := ExtractBucketParameter(eCtx) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - object, err := s.state.GetObjectStorage().GetObjectInfo(ctx, bucket, jsonForm.FilePath) + span.SetAttributes(attribute.String("bucket", bucket)) + + var jsonForm form.GetFileAttributesForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - return eCtx.JSON(200, form.ObjectFromDomain(*object)) + objectStorage := s.state.GetObjectStorage() + object, err := objectStorage.GetObjectInfo(ctx, bucket, jsonForm.FilePath) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + objectSchema := form.ObjectFromDomain(*object) + return eCtx.Status(fiber.StatusOK).JSON(objectSchema) } // ShareFile @@ -364,27 +623,44 @@ func (s *Server) GetFileInfo(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket name to share file" // @Param jsonQuery body form.ShareFileForm true "Parameters to share file" -// @Success 200 {object} form.ResponseForm "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.Success "URL with shared file" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket of object not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/cloud/{bucket}/file/share [post] -func (s *Server) ShareFile(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") +func (s *Server) ShareFile(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - shareForm := &form.ShareFileForm{} - decoder := json.NewDecoder(eCtx.Request().Body) - err := decoder.Decode(shareForm) + bucket, err := ExtractBucketParameter(eCtx) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - expired := time.Second * time.Duration(shareForm.ExpiredSecs) - params := &domain.ShareObjectParams{FilePath: shareForm.FilePath, Expired: expired} - url, err := s.state.GetObjectStorage().GenShareURL(ctx, bucket, params) + span.SetAttributes(attribute.String("bucket", bucket)) + + var jsonForm form.ShareFileForm + err = json.Unmarshal(eCtx.Body(), &jsonForm) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + expired := time.Second * time.Duration(jsonForm.ExpiredSecs) + params := &domain.ShareObjectParams{FilePath: jsonForm.FilePath, Expired: expired} + + objectStorage := s.state.GetObjectStorage() + url, err := objectStorage.GenShareURL(ctx, bucket, params) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(200, form.CreateStatusResponse(url)) + return eCtx.Status(fiber.StatusOK).JSON(form.SuccessResponse(url)) } diff --git a/cmd/watchtower/httpserver/routes_system.go b/cmd/watchtower/httpserver/routes_system.go index 398ced8..ec05cc5 100644 --- a/cmd/watchtower/httpserver/routes_system.go +++ b/cmd/watchtower/httpserver/routes_system.go @@ -1,21 +1,21 @@ package httpserver import ( - "net/http" "os" - "github.com/labstack/echo/v4" + "github.com/gofiber/fiber/v2" ) -func (s *Server) CreateSystemGroup() error { - s.server.GET("/", s.Home) - return nil +func (s *Server) CreateSystemGroup(group fiber.Router) { + group.Get("/", s.Home) } -func (s *Server) Home(eCtx echo.Context) error { +func (s *Server) Home(eCtx *fiber.Ctx) error { fileData, err := os.ReadFile("./static/index.html") if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return eCtx.SendStatus(fiber.StatusInternalServerError) } - return eCtx.HTMLBlob(http.StatusOK, fileData) + + eCtx.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + return eCtx.SendString(string(fileData)) } diff --git a/cmd/watchtower/httpserver/routes_task.go b/cmd/watchtower/httpserver/routes_task.go index 3ee7a1d..93cb0ad 100644 --- a/cmd/watchtower/httpserver/routes_task.go +++ b/cmd/watchtower/httpserver/routes_task.go @@ -1,25 +1,21 @@ package httpserver import ( - "net/http" - "strconv" - + "github.com/gofiber/fiber/v2" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "golang.org/x/exp/slices" - "github.com/google/uuid" - "github.com/labstack/echo/v4" - "watchtower/cmd/watchtower/httpserver/form" + task "watchtower/internal/support/task/domain" ) -func (s *Server) CreateTasksGroup() error { - group := s.server.Group("/api/v1/tasks") - - group.GET("/:bucket", s.LoadTasks) - group.GET("/:bucket/:task_id", s.LoadTaskByID) - - return nil +func (s *Server) CreateTasksGroup(group fiber.Router) { + tasksGroup := group.Group("/tasks") + tasksGroup.Get("/:bucket", s.LoadTasks) + tasksGroup.Get("/:bucket/:task_id", s.LoadTaskByID) } // LoadTasks @@ -31,43 +27,54 @@ func (s *Server) CreateTasksGroup() error { // @Produce json // @Param bucket path string true "Bucket id of uploaded files" // @Param status query string false "Status tasks to filter target result" -// @Success 200 {object} []form.TaskSchema "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} []form.TaskSchema "Loaded tasks" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Bucket not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/tasks/{bucket} [get] -func (s *Server) LoadTasks(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") - if bucket == "" { - return echo.NewHTTPError(http.StatusBadRequest, "bucket is required") - } +func (s *Server) LoadTasks(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - tasks, err := s.state.GetTaskProcessor().GetBucketTasks(ctx, bucket) + bucket, err := ExtractBucketParameter(eCtx) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - status := eCtx.QueryParam("status") - if status == "" { - return eCtx.JSON(200, tasks) + span.SetAttributes(attribute.String("bucket", bucket)) + + status, err := ExtractTaskStatusParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString("unknown status") } - inputTaskStatus, err := strconv.Atoi(status) + span.SetAttributes(attribute.Int("status", status)) + + taskStorage := s.state.GetTaskProcessor() + tasks, err := taskStorage.GetBucketTasks(ctx, bucket) if err != nil { - return echo.NewHTTPError(http.StatusUnprocessableEntity, "unknown status") + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - taskStatus := task.TaskStatus(inputTaskStatus) + taskStatus := task.TaskStatus(status) foundedTasks := slices.DeleteFunc(tasks, func(task *task.Task) bool { return task.Status != taskStatus }) foundedTasksDto := make([]form.TaskSchema, len(foundedTasks)) - for index, task := range foundedTasks { - foundedTasksDto[index] = form.TaskFromDomain(*task) + for index, taskIt := range foundedTasks { + foundedTasksDto[index] = form.TaskFromDomain(*taskIt) } - return eCtx.JSON(200, foundedTasksDto) + return eCtx.Status(fiber.StatusOK).JSON(foundedTasksDto) } // LoadTaskByID @@ -79,31 +86,39 @@ func (s *Server) LoadTasks(eCtx echo.Context) error { // @Produce json // @Param bucket path string true "Bucket id of processing task" // @Param task_id path string true "Task ID" -// @Success 200 {object} form.TaskSchema "Ok" -// @Failure 400 {object} form.BadRequestForm "Bad Request message" -// @Failure 503 {object} form.ServerErrorForm "Server does not available" +// @Success 200 {object} form.TaskSchema "Loaded tasks" +// @Failure 400 {object} form.BadRequestError "Bad Request error" +// @Failure 404 {object} form.NotFoundError "Task not found" +// @Failure 500 {object} form.InternalServerError "Internal server error" +// @Failure 503 {object} form.ServerUnavailableError "Server does not available" // @Router /api/v1/tasks/{bucket}/{task_id} [get] -func (s *Server) LoadTaskByID(eCtx echo.Context) error { - ctx := eCtx.Request().Context() - bucket := eCtx.Param("bucket") - if bucket == "" { - return echo.NewHTTPError(http.StatusBadRequest, "bucket is required") - } +func (s *Server) LoadTaskByID(eCtx *fiber.Ctx) error { + ctx := eCtx.UserContext() + + span := trace.SpanFromContext(ctx) - taskIDParam := eCtx.Param("task_id") - if taskIDParam == "" { - return echo.NewHTTPError(http.StatusBadRequest, "task_id is required") + bucket, err := ExtractBucketParameter(eCtx) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - taskID, err := uuid.Parse(taskIDParam) + taskID, err := ExtractTaskIDParameter(eCtx) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusBadRequest).SendString(err.Error()) } - task, err := s.state.GetTaskProcessor().GetTask(ctx, bucket, taskID) + taskStorage := s.state.GetTaskProcessor() + foundedTask, err := taskStorage.GetTask(ctx, bucket, taskID) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return eCtx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } - return eCtx.JSON(200, form.TaskFromDomain(*task)) + taskSchema := form.TaskFromDomain(*foundedTask) + return eCtx.Status(fiber.StatusOK).JSON(taskSchema) } diff --git a/cmd/watchtower/watchtower.go b/cmd/watchtower/watchtower.go index 49e3e35..0d5af78 100644 --- a/cmd/watchtower/watchtower.go +++ b/cmd/watchtower/watchtower.go @@ -7,12 +7,14 @@ import ( "os" "os/signal" "syscall" + "time" + + "github.com/breadrock1/otlp-go/otlp" "watchtower/cmd" "watchtower/cmd/watchtower/httpserver" "watchtower/internal/core/cloud/infrastructure/s3" "watchtower/internal/process" - "watchtower/internal/shared/telemetry" "watchtower/internal/support/task/infrastructure/docparser" "watchtower/internal/support/task/infrastructure/docsearch" "watchtower/internal/support/task/infrastructure/redis" @@ -22,48 +24,69 @@ import ( taskApp "watchtower/internal/support/task/application" ) +const ( + ShutdownDuration = 10 * time.Second +) + func main() { - ctx := context.Background() servConfig := cmd.Execute() - traceProvider, err := telemetry.InitTracer(servConfig.Otlp.Tracer) - if err != nil { - slog.Warn("failed to init tracer", slog.String("err", err.Error())) - } + logger := otlp_go.InitLocalLogger(servConfig.Otlp.Logger) + slog.SetDefault(logger) + + ctx := context.Background() + cCtx, cancel := context.WithCancel(ctx) taskStorage := redis.New(servConfig.Task.TaskStorage.Redis) taskQueue, err := rmq.New(servConfig.Task.TaskQueue.Rmq) if err != nil { log.Fatalf("task queue connection failed: %v", err) } - err = taskQueue.StartConsuming(ctx) + err = taskQueue.StartConsuming(cCtx) if err != nil { - log.Fatalf("failed to launch task queue consumer: %v", err) + slog.Error("failed to launch task queue consumer", slog.String("err", err.Error())) + os.Exit(1) } docParser := docparser.New(servConfig.Task.Processor.DocParser) docStorage := docsearch.New(servConfig.Task.Processor.DocStorage) objStorage, err := s3.New(servConfig.Storage.S3) if err != nil { - log.Fatalf("object storage connection failed: %v", err) + slog.Error("object storage connection failed", slog.String("err", err.Error())) + os.Exit(1) } - cCtx, cancel := context.WithCancel(ctx) storageUseCase := cloudApp.NewStorageUseCase(objStorage) taskUseCase := taskApp.NewTaskUseCase(taskStorage, taskQueue, docParser, docStorage) orchestrator := process.NewOrchestrator(servConfig.Orchestrator, storageUseCase, taskUseCase) orchestrator.LaunchListener(cCtx) - httpServer := httpserver.SetupServer(servConfig.Otlp, orchestrator, traceProvider) + httpServer := httpserver.SetupServer(servConfig.Otlp, orchestrator) go func() { - if err := httpServer.Start(cCtx, servConfig.Server.Http); err != nil { - log.Fatalf("http server start failed: %v", err) + if err := httpServer.Start(servConfig.Server.Http); err != nil { + slog.Error("http server start failed", slog.String("err", err.Error())) + os.Exit(1) } }() ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) <-ch + + slog.Warn("received shutdown signal. shutdown server...") + cancel() + + shutdownCtx, shutdownRelease := context.WithTimeout(ctx, ShutdownDuration) + defer shutdownRelease() + + if err = httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("http server shutdown failed", slog.String("err", err.Error())) + return + } + + time.Sleep(time.Second) + + slog.Info("application has been shutdown successfully") } diff --git a/configs/testing.toml b/configs/development.toml similarity index 100% rename from configs/testing.toml rename to configs/development.toml diff --git a/configs/config.toml b/configs/production.toml similarity index 100% rename from configs/config.toml rename to configs/production.toml diff --git a/docs/docs.go b/docs/docs.go index 3de2fa7..fba0e5b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -44,19 +44,25 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -75,18 +81,24 @@ const docTemplate = `{ "operationId": "get-buckets", "responses": { "200": { - "description": "Ok", + "description": "Loaded buckets info", "schema": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/form.BucketSchema" } } }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" + } + }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -116,19 +128,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -165,19 +189,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -219,19 +255,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket or Object not found", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -273,19 +321,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket or file not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -325,21 +385,33 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Ok", + "description": "Returned file bytes", "schema": { "type": "file" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -381,19 +453,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket or file not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -432,19 +516,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -484,21 +580,33 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Ok", + "description": "URL with shared file", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket of object not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -544,19 +652,31 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -598,19 +718,161 @@ const docTemplate = `{ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" + } + } + } + } + }, + "/api/v1/cloud/{bucket}/folder": { + "post": { + "description": "Create empty folder into cloud storage", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Create empty folder into cloud storage", + "operationId": "create-folder", + "parameters": [ + { + "type": "string", + "description": "Bucket name to create folder", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "Params to create folder", + "name": "jsonQuery", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/form.FolderForm" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/form.Success" + } + }, + "400": { + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" + } + }, + "503": { + "description": "Server does not available", + "schema": { + "$ref": "#/definitions/form.ServerUnavailableError" + } + } + } + }, + "delete": { + "description": "Delete empty folder into cloud storage", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Delete folder into cloud storage", + "operationId": "delete-folder", + "parameters": [ + { + "type": "string", + "description": "Bucket name to delete folder", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "Params to delete folder", + "name": "jsonQuery", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/form.FolderForm" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/form.Success" + } + }, + "400": { + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" + } + }, + "503": { + "description": "Server does not available", + "schema": { + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -647,7 +909,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Ok", + "description": "Loaded tasks", "schema": { "type": "array", "items": { @@ -656,15 +918,27 @@ const docTemplate = `{ } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -702,21 +976,33 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Ok", + "description": "Loaded tasks", "schema": { "$ref": "#/definitions/form.TaskSchema" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Task not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -724,7 +1010,7 @@ const docTemplate = `{ } }, "definitions": { - "form.BadRequestForm": { + "form.BadRequestError": { "type": "object", "properties": { "message": { @@ -737,6 +1023,20 @@ const docTemplate = `{ } } }, + "form.BucketSchema": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "form.CopyFileForm": { "type": "object", "properties": { @@ -768,6 +1068,15 @@ const docTemplate = `{ } } }, + "form.FolderForm": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "example": "test-folder" + } + } + }, "form.GetFileAttributesForm": { "type": "object", "properties": { @@ -786,34 +1095,47 @@ const docTemplate = `{ } } }, - "form.RemoveFileForm": { + "form.InternalServerError": { "type": "object", "properties": { - "file_name": { + "message": { "type": "string", - "example": "test-file.docx" + "example": "Internal server error message" + }, + "status": { + "type": "integer", + "example": 500 } } }, - "form.ResponseForm": { + "form.NotFoundError": { "type": "object", "properties": { "message": { "type": "string", - "example": "Done" + "example": "Not found" }, "status": { "type": "integer", - "example": 200 + "example": 404 + } + } + }, + "form.RemoveFileForm": { + "type": "object", + "properties": { + "file_name": { + "type": "string", + "example": "test-file.docx" } } }, - "form.ServerErrorForm": { + "form.ServerUnavailableError": { "type": "object", "properties": { "message": { "type": "string", - "example": "Server Error message" + "example": "Server unavailable error message" }, "status": { "type": "integer", @@ -834,6 +1156,19 @@ const docTemplate = `{ } } }, + "form.Success": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Done" + }, + "status": { + "type": "integer", + "example": 200 + } + } + }, "form.TaskSchema": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 93fddbf..98bcee5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -33,19 +33,25 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -64,18 +70,24 @@ "operationId": "get-buckets", "responses": { "200": { - "description": "Ok", + "description": "Loaded buckets info", "schema": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/form.BucketSchema" } } }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" + } + }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -105,19 +117,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -154,19 +178,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -208,19 +244,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket or Object not found", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -262,19 +310,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket or file not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -314,21 +374,33 @@ ], "responses": { "200": { - "description": "Ok", + "description": "Returned file bytes", "schema": { "type": "file" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -370,19 +442,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket or file not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -421,19 +505,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -473,21 +569,33 @@ ], "responses": { "200": { - "description": "Ok", + "description": "URL with shared file", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket of object not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -533,19 +641,31 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -587,19 +707,161 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/form.ResponseForm" + "$ref": "#/definitions/form.Success" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" + } + } + } + } + }, + "/api/v1/cloud/{bucket}/folder": { + "post": { + "description": "Create empty folder into cloud storage", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Create empty folder into cloud storage", + "operationId": "create-folder", + "parameters": [ + { + "type": "string", + "description": "Bucket name to create folder", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "Params to create folder", + "name": "jsonQuery", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/form.FolderForm" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/form.Success" + } + }, + "400": { + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" + } + }, + "503": { + "description": "Server does not available", + "schema": { + "$ref": "#/definitions/form.ServerUnavailableError" + } + } + } + }, + "delete": { + "description": "Delete empty folder into cloud storage", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Delete folder into cloud storage", + "operationId": "delete-folder", + "parameters": [ + { + "type": "string", + "description": "Bucket name to delete folder", + "name": "bucket", + "in": "path", + "required": true + }, + { + "description": "Params to delete folder", + "name": "jsonQuery", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/form.FolderForm" + } + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/form.Success" + } + }, + "400": { + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" + } + }, + "503": { + "description": "Server does not available", + "schema": { + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -636,7 +898,7 @@ ], "responses": { "200": { - "description": "Ok", + "description": "Loaded tasks", "schema": { "type": "array", "items": { @@ -645,15 +907,27 @@ } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Bucket not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -691,21 +965,33 @@ ], "responses": { "200": { - "description": "Ok", + "description": "Loaded tasks", "schema": { "$ref": "#/definitions/form.TaskSchema" } }, "400": { - "description": "Bad Request message", + "description": "Bad Request error", + "schema": { + "$ref": "#/definitions/form.BadRequestError" + } + }, + "404": { + "description": "Task not found", + "schema": { + "$ref": "#/definitions/form.NotFoundError" + } + }, + "500": { + "description": "Internal server error", "schema": { - "$ref": "#/definitions/form.BadRequestForm" + "$ref": "#/definitions/form.InternalServerError" } }, "503": { "description": "Server does not available", "schema": { - "$ref": "#/definitions/form.ServerErrorForm" + "$ref": "#/definitions/form.ServerUnavailableError" } } } @@ -713,7 +999,7 @@ } }, "definitions": { - "form.BadRequestForm": { + "form.BadRequestError": { "type": "object", "properties": { "message": { @@ -726,6 +1012,20 @@ } } }, + "form.BucketSchema": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "form.CopyFileForm": { "type": "object", "properties": { @@ -757,6 +1057,15 @@ } } }, + "form.FolderForm": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "example": "test-folder" + } + } + }, "form.GetFileAttributesForm": { "type": "object", "properties": { @@ -775,34 +1084,47 @@ } } }, - "form.RemoveFileForm": { + "form.InternalServerError": { "type": "object", "properties": { - "file_name": { + "message": { "type": "string", - "example": "test-file.docx" + "example": "Internal server error message" + }, + "status": { + "type": "integer", + "example": 500 } } }, - "form.ResponseForm": { + "form.NotFoundError": { "type": "object", "properties": { "message": { "type": "string", - "example": "Done" + "example": "Not found" }, "status": { "type": "integer", - "example": 200 + "example": 404 + } + } + }, + "form.RemoveFileForm": { + "type": "object", + "properties": { + "file_name": { + "type": "string", + "example": "test-file.docx" } } }, - "form.ServerErrorForm": { + "form.ServerUnavailableError": { "type": "object", "properties": { "message": { "type": "string", - "example": "Server Error message" + "example": "Server unavailable error message" }, "status": { "type": "integer", @@ -823,6 +1145,19 @@ } } }, + "form.Success": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Done" + }, + "status": { + "type": "integer", + "example": 200 + } + } + }, "form.TaskSchema": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6e7579c..15af7ce 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,5 @@ definitions: - form.BadRequestForm: + form.BadRequestError: properties: message: example: Bad Request message @@ -8,6 +8,15 @@ definitions: example: 400 type: integer type: object + form.BucketSchema: + properties: + created_at: + type: string + id: + type: string + path: + type: string + type: object form.CopyFileForm: properties: dst_path: @@ -29,6 +38,12 @@ definitions: example: test-file.docx type: string type: object + form.FolderForm: + properties: + prefix: + example: test-folder + type: string + type: object form.GetFileAttributesForm: properties: file_path: @@ -41,25 +56,34 @@ definitions: example: test-folder/ type: string type: object - form.RemoveFileForm: + form.InternalServerError: properties: - file_name: - example: test-file.docx + message: + example: Internal server error message type: string + status: + example: 500 + type: integer type: object - form.ResponseForm: + form.NotFoundError: properties: message: - example: Done + example: Not found type: string status: - example: 200 + example: 404 type: integer type: object - form.ServerErrorForm: + form.RemoveFileForm: + properties: + file_name: + example: test-file.docx + type: string + type: object + form.ServerUnavailableError: properties: message: - example: Server Error message + example: Server unavailable error message type: string status: example: 503 @@ -74,6 +98,15 @@ definitions: example: test-file.docx type: string type: object + form.Success: + properties: + message: + example: Done + type: string + status: + example: 200 + type: integer + type: object form.TaskSchema: properties: bucket_id: @@ -112,15 +145,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Remove bucket from cloud tags: - buckets @@ -145,15 +186,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Remove file from cloud tags: - files @@ -181,15 +230,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket or Object not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Get file attributes tags: - files @@ -217,15 +274,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket or file not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Copy file to another location into bucket tags: - files @@ -251,17 +316,25 @@ paths: - application/json responses: "200": - description: Ok + description: Returned file bytes schema: type: file "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Download file from cloud tags: - files @@ -289,15 +362,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket or file not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Move file to another location into bucket tags: - files @@ -323,15 +404,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Remove file from cloud tags: - files @@ -357,17 +446,25 @@ paths: - application/json responses: "200": - description: Ok + description: URL with shared file schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket of object not found schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Get share URL for file tags: - share @@ -398,15 +495,23 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Upload files to cloud tags: - files @@ -434,18 +539,113 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Get files list into bucket tags: - files + /api/v1/cloud/{bucket}/folder: + delete: + consumes: + - application/json + description: Delete empty folder into cloud storage + operationId: delete-folder + parameters: + - description: Bucket name to delete folder + in: path + name: bucket + required: true + type: string + - description: Params to delete folder + in: body + name: jsonQuery + required: true + schema: + $ref: '#/definitions/form.FolderForm' + produces: + - application/json + responses: + "200": + description: Ok + schema: + $ref: '#/definitions/form.Success' + "400": + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' + "503": + description: Server does not available + schema: + $ref: '#/definitions/form.ServerUnavailableError' + summary: Delete folder into cloud storage + tags: + - files + post: + consumes: + - application/json + description: Create empty folder into cloud storage + operationId: create-folder + parameters: + - description: Bucket name to create folder + in: path + name: bucket + required: true + type: string + - description: Params to create folder + in: body + name: jsonQuery + required: true + schema: + $ref: '#/definitions/form.FolderForm' + produces: + - application/json + responses: + "200": + description: Ok + schema: + $ref: '#/definitions/form.Success' + "400": + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' + "503": + description: Server does not available + schema: + $ref: '#/definitions/form.ServerUnavailableError' + summary: Create empty folder into cloud storage + tags: + - files /api/v1/cloud/bucket: put: consumes: @@ -465,15 +665,19 @@ paths: "200": description: Ok schema: - $ref: '#/definitions/form.ResponseForm' + $ref: '#/definitions/form.Success' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Create new bucket into cloud tags: - buckets @@ -485,15 +689,19 @@ paths: - application/json responses: "200": - description: Ok + description: Loaded buckets info schema: items: - type: string + $ref: '#/definitions/form.BucketSchema' type: array + "500": + description: Internal server error + schema: + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Get watched bucket list tags: - buckets @@ -517,19 +725,27 @@ paths: - application/json responses: "200": - description: Ok + description: Loaded tasks schema: items: $ref: '#/definitions/form.TaskSchema' type: array "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Bucket not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Load processing tasks of uploaded files into bucket tags: - tasks @@ -554,17 +770,25 @@ paths: - application/json responses: "200": - description: Ok + description: Loaded tasks schema: $ref: '#/definitions/form.TaskSchema' "400": - description: Bad Request message + description: Bad Request error + schema: + $ref: '#/definitions/form.BadRequestError' + "404": + description: Task not found + schema: + $ref: '#/definitions/form.NotFoundError' + "500": + description: Internal server error schema: - $ref: '#/definitions/form.BadRequestForm' + $ref: '#/definitions/form.InternalServerError' "503": description: Server does not available schema: - $ref: '#/definitions/form.ServerErrorForm' + $ref: '#/definitions/form.ServerUnavailableError' summary: Load processing task by id tags: - tasks diff --git a/go.mod b/go.mod index 84e1bc2..3f068ba 100644 --- a/go.mod +++ b/go.mod @@ -1,67 +1,69 @@ module watchtower -go 1.25.3 +go 1.25.7 require ( + github.com/breadrock1/otlp-go v0.0.2 + github.com/gofiber/fiber/v2 v2.52.12 + github.com/gofiber/swagger v1.1.1 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.4 github.com/minio/minio-go/v7 v7.0.94 + github.com/prometheus/client_golang v1.23.2 github.com/rabbitmq/amqp091-go v1.10.0 github.com/redis/go-redis/v9 v9.11.0 - github.com/samber/slog-loki/v2 v2.2.0 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.11.1 - github.com/swaggo/echo-swagger v1.4.1 github.com/swaggo/swag v1.16.6 - go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.63.0 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 - go.opentelemetry.io/otel/sdk v1.38.0 - go.opentelemetry.io/otel/trace v1.38.0 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 - golang.org/x/sync v0.18.0 + golang.org/x/sync v0.20.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Marlliton/slogpretty v0.1.3 // indirect github.com/afiskon/promtail-client v0.0.0-20190305142237-506f3f921e9c // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/ansrivas/fiberprometheus/v2 v2.17.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/ghodss/yaml v1.0.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.1 // indirect - github.com/go-openapi/jsonreference v0.21.2 // indirect - github.com/go-openapi/spec v0.22.0 // indirect - github.com/go-openapi/swag/conv v0.25.1 // indirect - github.com/go-openapi/swag/jsonname v0.25.1 // indirect - github.com/go-openapi/swag/jsonutils v0.25.1 // indirect - github.com/go-openapi/swag/loading v0.25.1 // indirect - github.com/go-openapi/swag/stringutils v0.25.1 // indirect - github.com/go-openapi/swag/typeutils v0.25.1 // indirect - github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gofiber/contrib/otelfiber/v2 v2.2.3 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/minio/crc64nvme v1.0.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -69,16 +71,15 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/samber/lo v1.38.1 // indirect github.com/samber/slog-common v0.11.0 // indirect + github.com/samber/slog-loki/v2 v2.2.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 @@ -88,24 +89,28 @@ require ( github.com/swaggo/files/v2 v2.0.2 // indirect github.com/tinylib/msgp v1.3.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/grpc v1.75.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7bb8984..baf5252 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Marlliton/slogpretty v0.1.3 h1:kLYjcKtFqikoCrXVMaI2R6fBy9pcJwoBJKdkhwGgoB4= +github.com/Marlliton/slogpretty v0.1.3/go.mod h1:vEC85AhV7Obb264VOAUMIBvwE3ivRSad6djal/v2sYU= github.com/afiskon/promtail-client v0.0.0-20190305142237-506f3f921e9c h1:AMDVOKGaiqse4qiRXSzRgpC9DCNTHCx6zpzdtXXrKM4= github.com/afiskon/promtail-client v0.0.0-20190305142237-506f3f921e9c/go.mod h1:p/7Wos+jcfrnwLqqzJMZ0s323kfVtJPW+HUvAANklVQ= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/ansrivas/fiberprometheus/v2 v2.17.0 h1:p0gqs5LsSCWGoSFF44fCJkyU+XcE6TLRqEMu80b2iCo= +github.com/ansrivas/fiberprometheus/v2 v2.17.0/go.mod h1:giWBvbFSHOHG8N2wjYhQG23oc/2pF9v1mN8CdZs5Z2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/breadrock1/otlp-go v0.0.2 h1:vys3XlLGHk6FO/PY0TFdAAqSSgETikGd5yLU+soYch8= +github.com/breadrock1/otlp-go v0.0.2/go.mod h1:sFVCxvuE0dA+rTEPIxi0yzdBMs5zjdzGu8CjfcB4XQg= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -12,6 +20,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= @@ -25,8 +35,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk 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/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -34,31 +42,41 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= -github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= -github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= -github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= -github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= -github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= -github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= -github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= -github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= -github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= -github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= -github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= -github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= -github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= -github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= -github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= -github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= -github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= -github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofiber/contrib/otelfiber/v2 v2.2.3 h1:WKW1XezHFAoohGZwnvC0R8TFJcNkabQwB5YIpdKmz00= +github.com/gofiber/contrib/otelfiber/v2 v2.2.3/go.mod h1:WdQ1tYbL83IYC6oBaWvKBMVGSAYvSTRuUWTcr0wK1T4= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA= +github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= @@ -67,16 +85,16 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -86,8 +104,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= -github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -98,6 +114,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -115,14 +133,14 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI 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/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= @@ -168,8 +186,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= -github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= @@ -178,72 +194,74 @@ github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.63.0 h1:6YeICKmGrvgJ5th4+OMNpcuoB6q/Xs8gt0YCO7MUv1k= -go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.63.0/go.mod h1:ZEA7j2B35siNV0T00aapacNzjz4tvOlNoHp0ncCfwNQ= -go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo= -go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib v1.42.0 h1:845qj52z2T/bLInfZmG8AdbTO7delSd6eGVVHcAikzw= +go.opentelemetry.io/contrib v1.42.0/go.mod h1:JYdNU7Pl/2ckKMGp8/G7zeyhEbtRmy9Q8bcrtv75Znk= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/cloud/application/usecase.go b/internal/core/cloud/application/usecase.go index e4c0c9c..77f30e5 100644 --- a/internal/core/cloud/application/usecase.go +++ b/internal/core/cloud/application/usecase.go @@ -1,18 +1,16 @@ package application import ( - "context" "fmt" + "github.com/breadrock1/otlp-go/otlp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "watchtower/internal/core/cloud/domain" - "watchtower/internal/shared/telemetry" + "watchtower/internal/shared/kernel" ) -type Ctx context.Context - type StorageUseCase struct { cloudStorage domain.ICloudStorage } @@ -21,8 +19,8 @@ func NewStorageUseCase(cloudStorage domain.ICloudStorage) *StorageUseCase { return &StorageUseCase{cloudStorage: cloudStorage} } -func (s *StorageUseCase) GetAllBuckets(ctx Ctx) ([]domain.Bucket, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "get-buckets") +func (s *StorageUseCase) GetAllBuckets(ctx kernel.Ctx) ([]domain.Bucket, error) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "get-buckets") defer span.End() allBuckets, err := s.cloudStorage.GetAllBuckets(ctx) @@ -36,8 +34,8 @@ func (s *StorageUseCase) GetAllBuckets(ctx Ctx) ([]domain.Bucket, error) { return allBuckets, err } -func (s *StorageUseCase) CreateBucket(ctx Ctx, bucketID domain.BucketID) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "create-bucket") +func (s *StorageUseCase) CreateBucket(ctx kernel.Ctx, bucketID kernel.BucketID) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "create-bucket") defer span.End() span.SetAttributes(attribute.String("bucket", bucketID)) @@ -53,8 +51,8 @@ func (s *StorageUseCase) CreateBucket(ctx Ctx, bucketID domain.BucketID) error { return nil } -func (s *StorageUseCase) DeleteBucket(ctx Ctx, bucketID domain.BucketID) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "remove-bucket") +func (s *StorageUseCase) DeleteBucket(ctx kernel.Ctx, bucketID kernel.BucketID) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "remove-bucket") defer span.End() span.SetAttributes(attribute.String("bucket", bucketID)) @@ -69,8 +67,8 @@ func (s *StorageUseCase) DeleteBucket(ctx Ctx, bucketID domain.BucketID) error { return nil } -func (s *StorageUseCase) IsBucketExists(ctx Ctx, bucketID domain.BucketID) (bool, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "is-bucket-exists") +func (s *StorageUseCase) IsBucketExists(ctx kernel.Ctx, bucketID kernel.BucketID) (bool, error) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "is-bucket-exists") defer span.End() span.SetAttributes(attribute.String("bucket", bucketID)) @@ -87,11 +85,11 @@ func (s *StorageUseCase) IsBucketExists(ctx Ctx, bucketID domain.BucketID) (bool } func (s *StorageUseCase) GetObjectInfo( - ctx Ctx, - bucketID domain.BucketID, - objID domain.ObjectID, + ctx kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, ) (*domain.Object, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "get-file-metadata") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "get-file-metadata") defer span.End() span.SetAttributes( @@ -110,14 +108,15 @@ func (s *StorageUseCase) GetObjectInfo( return &objectInfo, nil } -func (s *StorageUseCase) CopyObject(ctx Ctx, bucketID domain.BucketID, params *domain.CopyObjectParams) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "copy-object") +func (s *StorageUseCase) CopyObject(ctx kernel.Ctx, bucketID kernel.BucketID, params *domain.CopyObjectParams) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "copy-object") defer span.End() span.SetAttributes( attribute.String("bucket", bucketID), attribute.String("src-file-path", params.SourcePath), attribute.String("dst-file-path", params.DestinationPath), + attribute.Bool("with-removed", params.WithRemoving), ) err := s.cloudStorage.CopyObject(ctx, bucketID, params) @@ -130,8 +129,8 @@ func (s *StorageUseCase) CopyObject(ctx Ctx, bucketID domain.BucketID, params *d return nil } -func (s *StorageUseCase) DeleteObject(ctx Ctx, bucketID domain.BucketID, objID domain.ObjectID) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "copy-object") +func (s *StorageUseCase) DeleteObject(ctx kernel.Ctx, bucketID kernel.BucketID, objID kernel.ObjectID) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "copy-object") defer span.End() span.SetAttributes( @@ -149,41 +148,31 @@ func (s *StorageUseCase) DeleteObject(ctx Ctx, bucketID domain.BucketID, objID d return nil } -func (s *StorageUseCase) MoveObject(ctx Ctx, bucketID domain.BucketID, params *domain.CopyObjectParams) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "move-file") +func (s *StorageUseCase) DeleteObjects(ctx kernel.Ctx, bucketID kernel.BucketID, prefix string) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "copy-object") defer span.End() span.SetAttributes( attribute.String("bucket", bucketID), - attribute.String("src-file-path", params.SourcePath), - attribute.String("dst-file-path", params.DestinationPath), + attribute.String("prefix", prefix), ) - err := s.cloudStorage.CopyObject(ctx, bucketID, params) + err := s.cloudStorage.DeleteObjects(ctx, bucketID, prefix) if err != nil { - err = fmt.Errorf("failed to move object %s to %s: %w", params.SourcePath, params.DestinationPath, err) + err = fmt.Errorf("failed to remove objects %s: %w", bucketID, err) span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return err } - - err = s.cloudStorage.DeleteObject(ctx, bucketID, params.DestinationPath) - if err != nil { - err = fmt.Errorf("failed to delete object: %w", err) - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) - return err - } - return nil } -func (s *StorageUseCase) GetBucketObjects( - ctx Ctx, - bucketID domain.BucketID, +func (s *StorageUseCase) LoadBucketObjects( + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.GetObjectsParams, ) ([]domain.Object, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "get-bucket-objects") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "get-bucket-objects") defer span.End() span.SetAttributes( @@ -203,17 +192,16 @@ func (s *StorageUseCase) GetBucketObjects( } func (s *StorageUseCase) StoreObject( - ctx Ctx, - bucketID domain.BucketID, + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.UploadObjectParams, -) (domain.ObjectID, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "upload-object") +) (kernel.ObjectID, error) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "upload-object") defer span.End() span.SetAttributes( attribute.String("bucket", bucketID), attribute.String("file-path", params.FilePath), - attribute.Int64("expired", params.Expired.Unix()), attribute.Int("data-len", params.FileData.Len()), ) @@ -229,11 +217,11 @@ func (s *StorageUseCase) StoreObject( } func (s *StorageUseCase) GenShareURL( - ctx Ctx, - bucketID domain.BucketID, + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.ShareObjectParams, ) (string, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "share-object") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "share-object") defer span.End() span.SetAttributes( @@ -253,11 +241,11 @@ func (s *StorageUseCase) GenShareURL( } func (s *StorageUseCase) GetObjectData( - ctx Ctx, - bucketID domain.BucketID, - objID domain.ObjectID, + ctx kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, ) (domain.ObjectData, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "download-object") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "download-object") defer span.End() span.SetAttributes( diff --git a/internal/core/cloud/domain/bucket.go b/internal/core/cloud/domain/bucket.go index 1afc93b..e932c6b 100644 --- a/internal/core/cloud/domain/bucket.go +++ b/internal/core/cloud/domain/bucket.go @@ -1,11 +1,20 @@ package domain -import "time" - -type BucketID = string +import ( + "time" + "watchtower/internal/shared/kernel" +) +// Bucket represents a storage bucket/container in the cloud storage system. +// Buckets are used to organize objects and control access at the container level. type Bucket struct { - ID BucketID - Path string + // ID is the unique identifier for the bucket + ID kernel.BucketID + + // Path is the full path or URI to the bucket + // Example: "s3://my-bucket" or "gs://my-bucket" + Path string + + // CreatedAt indicates when the bucket was created CreatedAt time.Time } diff --git a/internal/core/cloud/domain/object.go b/internal/core/cloud/domain/object.go index 50abcfa..239b656 100644 --- a/internal/core/cloud/domain/object.go +++ b/internal/core/cloud/domain/object.go @@ -5,16 +5,40 @@ import ( "time" ) -type ObjectID = string +// ObjectData represents the actual content of a stored object. +// Using *bytes.Buffer allows efficient reading and writing of object data. type ObjectData = *bytes.Buffer +// Object represents metadata about a stored object/file in cloud storage. +// It contains all relevant information about the object without its actual data. type Object struct { - Name string - Path string - Checksum string - ContentType string - Expired time.Time + // Name is the base name of the object (filename) + // Example: "document.pdf" + Name string + + // Path is the full path to the object within the bucket + // Example: "folder/subfolder/document.pdf" + Path string + + // Checksum is a hash of the object content for integrity verification + // Usually MD5, SHA256, or provider-specific checksum + Checksum string + + // ContentType is the MIME type of the object + // Example: "application/pdf", "image/jpeg" + ContentType string + + // Expired indicates when the object expires and may be automatically deleted + // Zero value means the object never expires + Expired time.Time + + // LastModified is the timestamp of the last modification LastModified time.Time - Size int64 - IsDirectory bool + + // Size is the object size in bytes + Size int64 + + // IsDirectory indicates if this "object" actually represents a directory/folder + // Some storage systems treat folders as objects with special handling + IsDirectory bool } diff --git a/internal/core/cloud/domain/params.go b/internal/core/cloud/domain/params.go index 83acdd1..0040818 100644 --- a/internal/core/cloud/domain/params.go +++ b/internal/core/cloud/domain/params.go @@ -5,22 +5,68 @@ import ( "time" ) +// CopyObjectParams defines parameters for copying an object from one location to another +// within the same bucket or across different paths. type CopyObjectParams struct { - SourcePath string + // SourcePath is the full path of the source object + // Example: "documents/original/file.pdf" + SourcePath string + + // DestinationPath is the full path where the object should be copied + // Example: "backups/file.pdf" or "documents/copy/file.pdf" DestinationPath string + + // WithRemoving is bool flag to remove source path after copying. + WithRemoving bool } +// ShareObjectParams defines parameters for generating a shareable URL for an object. +// This enables temporary access to private objects. type ShareObjectParams struct { + // FilePath is the path to the object to share + // Example: "shared/report.pdf" FilePath string - Expired time.Duration + + // Expired specifies how long the shareable URL remains valid + // Example: 24 * time.Hour for a 24-hour link + Expired time.Duration } +// GetObjectsParams defines filtering and pagination parameters for listing objects. type GetObjectsParams struct { + // PrefixPath filters objects to those with paths starting with this prefix + // This effectively lists objects in a "directory" + // Example: "documents/2024/" to list all objects in the 2024 folder PrefixPath string + + // Limit limits the number of objects returned (pagination) + // Zero means use provider default + Limit int32 + + // Offset base (pagination) + // Zero means use offset from begin + Offset int32 + + // ContinuationToken for pagination through large result sets + ContinuationToken string } +// UploadObjectParams defines parameters for uploading a new object to storage. type UploadObjectParams struct { + // FilePath is the destination path for the uploaded object + // Example: "uploads/images/profile.jpg" FilePath string + + // FileData contains the actual content to upload FileData *bytes.Buffer - Expired *time.Time + + // ContentType specifies the MIME type (optional, auto-detected if not provided) + ContentType string + + // Expired sets an expiration time for the object (optional) + // If nil, the object never expires + Expired *time.Time + + // Metadata allows attaching custom key-value pairs to the object + Metadata map[string]string } diff --git a/internal/core/cloud/domain/storage.go b/internal/core/cloud/domain/storage.go index 7d4d1e6..149f034 100644 --- a/internal/core/cloud/domain/storage.go +++ b/internal/core/cloud/domain/storage.go @@ -1,10 +1,14 @@ package domain import ( - "context" "net/url" + + "watchtower/internal/shared/kernel" ) +// ICloudStorage defines the complete interface for cloud storage operations. +// It combines bucket management, object operations, and sharing capabilities +// into a unified API. type ICloudStorage interface { IBucketManager IObjectManager @@ -12,25 +16,267 @@ type ICloudStorage interface { IShareManager } +// IBucketManager defines operations for managing storage buckets/containers. +// Buckets are the top-level organizational units in cloud storage. type IBucketManager interface { - GetAllBuckets(ctx context.Context) ([]Bucket, error) - IsBucketExist(ctx context.Context, bucketID BucketID) (bool, error) - CreateBucket(ctx context.Context, bucketID BucketID) error - DeleteBucket(ctx context.Context, bucketID BucketID) error + // GetAllBuckets retrieves a list of all buckets available in the storage system. + // Returns a slice of Bucket objects or an error if the operation fails. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // + // Returns: + // - []Bucket: List of all buckets + // - error: ErrUnauthorized if credentials are invalid, + // ErrServiceUnavailable if cloud provider is unreachable, + // or other provider-specific errors + // + // Example: + // buckets, err := storage.GetAllBuckets(ctx) + // for _, bucket := range buckets { + // fmt.Printf("Bucket: %s, Created: %s\n", bucket.ID, bucket.CreatedAt) + // } + GetAllBuckets(ctx kernel.Ctx) ([]Bucket, error) + + // IsBucketExist checks if a bucket with the given ID exists. + // This is useful for validation before performing bucket operations. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket to check + // + // Returns: + // - bool: true if bucket exists, false otherwise + // - error: ErrUnauthorized if credentials are invalid, or other provider-specific errors + // + // Example: + // exists, err := storage.IsBucketExist(ctx, "my-app-data") + // if !exists { + // // Create bucket or handle missing bucket + // } + IsBucketExist(ctx kernel.Ctx, bucketID kernel.BucketID) (bool, error) + + // CreateBucket creates a new bucket with the specified ID. + // Bucket IDs must be globally unique across the storage system. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: Unique identifier for the new bucket + // + // Returns: + // - error: ErrBucketAlreadyExists if bucket ID is taken, + // ErrInvalidBucketID if ID format is invalid, + // ErrQuotaExceeded if account limit reached, + // or other provider-specific errors + // + // Example: + // err := storage.CreateBucket(ctx, "user-uploads-2024") + // if errors.Is(err, ErrBucketAlreadyExists) { + // // Handle existing bucket + // } + CreateBucket(ctx kernel.Ctx, bucketID kernel.BucketID) error + + // DeleteBucket removes an existing bucket and all its contents. + // This operation is irreversible and should be used with caution. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket to delete + // + // Returns: + // - error: ErrBucketNotFound if bucket doesn't exist, + // ErrBucketNotEmpty if bucket still contains objects, + // or other provider-specific errors + // + // Note: Some providers require the bucket to be empty before deletion. + DeleteBucket(ctx kernel.Ctx, bucketID kernel.BucketID) error } +// IObjectManager defines operations for managing individual objects/files +// within buckets. This includes CRUD operations and data access. type IObjectManager interface { - GetObjectInfo(ctx context.Context, bucketID BucketID, objID ObjectID) (Object, error) - GetObjectData(ctx context.Context, bucketID BucketID, objID ObjectID) (ObjectData, error) - StoreObject(ctx context.Context, bucketID BucketID, params *UploadObjectParams) (ObjectID, error) - CopyObject(ctx context.Context, bucketID BucketID, params *CopyObjectParams) error - DeleteObject(ctx context.Context, bucketID BucketID, objID ObjectID) error + // GetObjectInfo retrieves metadata about an object without downloading its content. + // Useful for checking object properties, size, or last modified time. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing the object + // - objID: ID or path of the object to inspect + // + // Returns: + // - Object: Complete object metadata (without data) + // - error: ErrObjectNotFound if object doesn't exist, + // ErrBucketNotFound if bucket doesn't exist, + // or other provider-specific errors + // + // Example: + // info, err := storage.GetObjectInfo(ctx, "documents", "reports/annual.pdf") + // if err == nil { + // fmt.Printf("Size: %d bytes, Modified: %s\n", info.Size, info.LastModified) + // } + GetObjectInfo(ctx kernel.Ctx, bucketID kernel.BucketID, objID kernel.ObjectID) (Object, error) + + // GetObjectData retrieves both the object metadata and its content. + // The object data is returned as a buffer that can be read or streamed. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing the object + // - objID: ID or path of the object to download + // + // Returns: + // - ObjectData: Buffer containing the object's content + // - error: ErrObjectNotFound if object doesn't exist, + // ErrBucketNotFound if bucket doesn't exist, + // or other provider-specific errors + // + // Example: + // data, err := storage.GetObjectData(ctx, "images", "profile.jpg") + // if err == nil { + // imgBytes := data.Bytes() + // // Process image data... + // } + GetObjectData(ctx kernel.Ctx, bucketID kernel.BucketID, objID kernel.ObjectID) (ObjectData, error) + + // StoreObject uploads a new object or replaces an existing one. + // If an object already exists at the specified path, it will be overwritten. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket to store the object in + // - params: Upload parameters including file path, data, and options + // + // Returns: + // - ObjectID: Unique identifier for the stored object + // - error: ErrBucketNotFound if bucket doesn't exist, + // ErrInvalidFilePath if path format is invalid, + // ErrQuotaExceeded if bucket or account limit reached, + // or other provider-specific errors + // + // Example: + // data := bytes.NewBuffer([]byte("file content")) + // params := &UploadObjectParams{ + // FilePath: "uploads/notes.txt", + // FileData: data, + // ContentType: "text/plain", + // Metadata: map[string]string{"author": "john"}, + // } + // objID, err := storage.StoreObject(ctx, "my-bucket", params) + StoreObject(ctx kernel.Ctx, bucketID kernel.BucketID, params *UploadObjectParams) (kernel.ObjectID, error) + + // CopyObject duplicates an object from one location to another within the same bucket. + // This operation is often more efficient than download+upload for large files. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing both source and destination + // - params: Copy parameters with source and destination paths + // + // Returns: + // - error: ErrObjectNotFound if source doesn't exist, + // ErrInvalidPath if destination path is invalid, + // or other provider-specific errors + // + // Example: + // params := &CopyObjectParams{ + // SourcePath: "originals/document.pdf", + // DestinationPath: "backups/document-2024.pdf", + // } + // err := storage.CopyObject(ctx, "documents", params) + CopyObject(ctx kernel.Ctx, bucketID kernel.BucketID, params *CopyObjectParams) error + + // DeleteObject permanently removes an object from storage. + // This operation cannot be undone. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing the object + // - objID: ID or path of the object to delete + // + // Returns: + // - error: ErrObjectNotFound if object doesn't exist (idempotent), + // ErrBucketNotFound if bucket doesn't exist, + // or other provider-specific errors + // + // Example: + // err := storage.DeleteObject(ctx, "temp-files", "cache/session-123.tmp") + // if err != nil && !errors.Is(err, ErrObjectNotFound) { + // // Handle error, but ignore "not found" as it's already gone + // } + DeleteObject(ctx kernel.Ctx, bucketID kernel.BucketID, objID kernel.ObjectID) error + + // DeleteObjects permanently removes an objects from storage. + // This operation cannot be undone. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing the object + // - prefix: relative path of object to delete + // + // Returns: + // - error: ErrObjectNotFound if object doesn't exist (idempotent), + // ErrBucketNotFound if bucket doesn't exist, + // or other provider-specific errors + // + // Example: + // err := storage.DeleteObjects(ctx, "temp-files", "cache") + // if err != nil && !errors.Is(err, ErrObjectNotFound) { + // // Handle error, but ignore "not found" as it's already gone + // } + DeleteObjects(ctx kernel.Ctx, bucketID kernel.BucketID, prefix string) error } +// IObjectWalker defines operations for listing and iterating through objects in a bucket. +// This is useful for directory listings, backups, and batch operations. type IObjectWalker interface { - GetBucketObjects(ctx context.Context, bucketID BucketID, params *GetObjectsParams) ([]Object, error) + // GetBucketObjects retrieves a list of objects in a bucket, optionally filtered by prefix. + // This implements directory-like listing functionality. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket to list objects from + // - params: Filtering and pagination parameters + // + // Returns: + // - []Object: Slice of object metadata matching the criteria + // - error: ErrBucketNotFound if bucket doesn't exist, or other provider-specific errors + // + // Example: + // params := &GetObjectsParams{ + // PrefixPath: "images/2024/", + // } + // objects, err := storage.GetBucketObjects(ctx, "media", params) + // for _, obj := range objects { + // fmt.Printf("Found: %s (%d bytes)\n", obj.Path, obj.Size) + // } + GetBucketObjects(ctx kernel.Ctx, bucketID kernel.BucketID, params *GetObjectsParams) ([]Object, error) } +// IShareManager defines operations for generating temporary access URLs to objects. +// This enables secure sharing of private objects without making them public. type IShareManager interface { - GenShareURL(ctx context.Context, bucketID BucketID, params *ShareObjectParams) (*url.URL, error) + // GenShareURL generates a time-limited URL that provides access to a private object. + // The URL includes authentication tokens and expires after the specified duration. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing the object + // - params: Sharing parameters including file path and expiration + // + // Returns: + // - *url.URL: Pre-signed URL that grants temporary access + // - error: ErrObjectNotFound if object doesn't exist, + // ErrInvalidExpiration if duration is invalid, + // or other provider-specific errors + // + // Example: + // params := &ShareObjectParams{ + // FilePath: "shared/report.pdf", + // Expired: 24 * time.Hour, + // } + // shareURL, err := storage.GenShareURL(ctx, "documents", params) + // if err == nil { + // fmt.Printf("Shareable link (valid for 24h): %s\n", shareURL.String()) + // } + GenShareURL(ctx kernel.Ctx, bucketID kernel.BucketID, params *ShareObjectParams) (*url.URL, error) } diff --git a/internal/core/cloud/infrastructure/s3/s3.go b/internal/core/cloud/infrastructure/s3/s3.go index 9d81289..2f5f992 100644 --- a/internal/core/cloud/infrastructure/s3/s3.go +++ b/internal/core/cloud/infrastructure/s3/s3.go @@ -2,7 +2,6 @@ package s3 import ( "bytes" - "context" "fmt" "log/slog" "net/url" @@ -12,6 +11,7 @@ import ( "github.com/minio/minio-go/v7/pkg/credentials" "watchtower/internal/core/cloud/domain" + "watchtower/internal/shared/kernel" ) type S3Client struct { @@ -30,6 +30,8 @@ func New(config Config) (domain.ICloudStorage, error) { return nil, fmt.Errorf("s3 connection error: %w", err) } + slog.Info("s3 connection established", slog.String("address", config.Address)) + client := &S3Client{ mc: s3Client, } @@ -37,7 +39,7 @@ func New(config Config) (domain.ICloudStorage, error) { return client, nil } -func (s *S3Client) GetAllBuckets(ctx context.Context) ([]domain.Bucket, error) { +func (s *S3Client) GetAllBuckets(ctx kernel.Ctx) ([]domain.Bucket, error) { buckets, err := s.mc.ListBuckets(ctx) if err != nil { err = fmt.Errorf("s3 error: %w", err) @@ -56,7 +58,7 @@ func (s *S3Client) GetAllBuckets(ctx context.Context) ([]domain.Bucket, error) { return bucketNames, nil } -func (s *S3Client) IsBucketExist(ctx context.Context, bucketID domain.BucketID) (bool, error) { +func (s *S3Client) IsBucketExist(ctx kernel.Ctx, bucketID kernel.BucketID) (bool, error) { result, err := s.mc.BucketExists(ctx, bucketID) if err != nil { err = fmt.Errorf("s3 error: %w", err) @@ -65,7 +67,7 @@ func (s *S3Client) IsBucketExist(ctx context.Context, bucketID domain.BucketID) return result, nil } -func (s *S3Client) CreateBucket(ctx context.Context, bucketID domain.BucketID) error { +func (s *S3Client) CreateBucket(ctx kernel.Ctx, bucketID kernel.BucketID) error { opts := minio.MakeBucketOptions{} if err := s.mc.MakeBucket(ctx, bucketID, opts); err != nil { err = fmt.Errorf("s3 error: %w", err) @@ -74,7 +76,7 @@ func (s *S3Client) CreateBucket(ctx context.Context, bucketID domain.BucketID) e return nil } -func (s *S3Client) DeleteBucket(ctx context.Context, bucketID domain.BucketID) error { +func (s *S3Client) DeleteBucket(ctx kernel.Ctx, bucketID kernel.BucketID) error { if err := s.mc.RemoveBucket(ctx, bucketID); err != nil { err = fmt.Errorf("s3 error: %w", err) return err @@ -83,9 +85,9 @@ func (s *S3Client) DeleteBucket(ctx context.Context, bucketID domain.BucketID) e } func (s *S3Client) GetObjectInfo( - ctx context.Context, - bucketID domain.BucketID, - objID domain.ObjectID, + ctx kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, ) (domain.Object, error) { var objectAttrs domain.Object opts := minio.StatObjectOptions{} @@ -111,9 +113,9 @@ func (s *S3Client) GetObjectInfo( } func (s *S3Client) GetObjectData( - ctx context.Context, - bucketID domain.BucketID, - objID domain.ObjectID, + ctx kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, ) (domain.ObjectData, error) { opts := minio.GetObjectOptions{} filePath := path.Clean(objID) @@ -134,10 +136,10 @@ func (s *S3Client) GetObjectData( } func (s *S3Client) StoreObject( - ctx context.Context, - bucketID domain.BucketID, + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.UploadObjectParams, -) (domain.ObjectID, error) { +) (kernel.ObjectID, error) { opts := minio.PutObjectOptions{} if params.Expired != nil { opts.Expires = *params.Expired @@ -153,7 +155,7 @@ func (s *S3Client) StoreObject( return filePath, nil } -func (s *S3Client) CopyObject(ctx context.Context, bucketID domain.BucketID, params *domain.CopyObjectParams) error { +func (s *S3Client) CopyObject(ctx kernel.Ctx, bucketID kernel.BucketID, params *domain.CopyObjectParams) error { srcPath := path.Clean(params.SourcePath) dstPath := path.Clean(params.DestinationPath) @@ -168,7 +170,33 @@ func (s *S3Client) CopyObject(ctx context.Context, bucketID domain.BucketID, par return nil } -func (s *S3Client) DeleteObject(ctx context.Context, bucketID domain.BucketID, objID domain.ObjectID) error { +func (s *S3Client) DeleteObjects(ctx kernel.Ctx, bucketID kernel.BucketID, prefix string) error { + listObjOpts := minio.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + UseV1: true, + } + objInfoCh := s.mc.ListObjects(ctx, bucketID, listObjOpts) + + removeObjOpts := minio.RemoveObjectsOptions{ + GovernanceBypass: true, + } + errCh := s.mc.RemoveObjects(ctx, bucketID, objInfoCh, removeObjOpts) + for err := range errCh { + if err.Err != nil { + slog.Warn("failed to delete object", + slog.String("bucket", bucketID), + slog.String("prefix", prefix), + slog.String("error", err.ObjectName), + slog.String("err", err.Err.Error()), + ) + } + } + + return nil +} + +func (s *S3Client) DeleteObject(ctx kernel.Ctx, bucketID kernel.BucketID, objID kernel.ObjectID) error { opts := minio.RemoveObjectOptions{} filePath := path.Clean(objID) if err := s.mc.RemoveObject(ctx, bucketID, filePath, opts); err != nil { @@ -179,8 +207,8 @@ func (s *S3Client) DeleteObject(ctx context.Context, bucketID domain.BucketID, o } func (s *S3Client) GetBucketObjects( - ctx context.Context, - bucketID domain.BucketID, + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.GetObjectsParams, ) ([]domain.Object, error) { opts := minio.ListObjectsOptions{ @@ -220,8 +248,8 @@ func (s *S3Client) GetBucketObjects( } func (s *S3Client) GenShareURL( - ctx context.Context, - bucketID domain.BucketID, + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.ShareObjectParams, ) (*url.URL, error) { filePath := path.Clean(params.FilePath) diff --git a/internal/process/orchestrator.go b/internal/process/orchestrator.go index afd5893..4640381 100644 --- a/internal/process/orchestrator.go +++ b/internal/process/orchestrator.go @@ -1,26 +1,25 @@ package process import ( - "context" "fmt" "log/slog" + "strconv" + "time" - "golang.org/x/sync/semaphore" - + "github.com/breadrock1/otlp-go/otlp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" + "golang.org/x/sync/semaphore" "watchtower/internal/core/cloud/domain" "watchtower/internal/shared/kernel" - "watchtower/internal/shared/telemetry" + "watchtower/internal/shared/metrics" cloudApp "watchtower/internal/core/cloud/application" taskUC "watchtower/internal/support/task/application" taskDomain "watchtower/internal/support/task/domain" ) -type Ctx = context.Context - type Orchestrator struct { config Config storageUC *cloudApp.StorageUseCase @@ -39,7 +38,8 @@ func (o *Orchestrator) GetTaskProcessor() *taskUC.TaskUseCase { return o.taskUC } -func (o *Orchestrator) LaunchListener(gCtx Ctx) { +func (o *Orchestrator) LaunchListener(ctx kernel.Ctx) { + slog.Info("starting orchestrator processing") go func() { consumeCh := o.taskUC.GetConsumerChannel() sem := semaphore.NewWeighted(o.config.SemaphoreSize) @@ -55,14 +55,27 @@ func (o *Orchestrator) LaunchListener(gCtx Ctx) { defer sem.Release(1) task := &cMsg.Body + + instant := time.Now() o.handleTask(ctx, task) + + elapsedTime := time.Since(instant) + statusInt := strconv.Itoa(int(task.Status)) + metrics.OrchestratorProcessingDurationSeconds. + WithLabelValues(kernel.AppName, statusInt). + Observe(elapsedTime.Seconds()) + o.taskUC.UpdateTaskStatus(ctx, task) + metrics.OrchestratorProcessingCounter. + WithLabelValues(kernel.AppName, statusInt). + Inc() + ctx.Done() }() - case <-gCtx.Done(): - slog.Info("terminating processing") + case <-ctx.Done(): + slog.Info("terminating orchestrator processing") return } } @@ -70,11 +83,11 @@ func (o *Orchestrator) LaunchListener(gCtx Ctx) { } func (o *Orchestrator) UploadFile( - ctx Ctx, - bucketID domain.BucketID, + ctx kernel.Ctx, + bucketID kernel.BucketID, params *domain.UploadObjectParams, ) (*taskDomain.Task, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "upload-file") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "upload-file") defer span.End() span.SetAttributes( @@ -84,6 +97,11 @@ func (o *Orchestrator) UploadFile( ) objID, err := o.storageUC.StoreObject(ctx, bucketID, params) + + metrics.UploadedFilesCounter. + WithLabelValues(kernel.AppName, strconv.FormatBool(err != nil)). + Inc() + if err != nil { err = fmt.Errorf("failed to upload file %s: %w", params.FilePath, err) span.SetStatus(codes.Error, err.Error()) @@ -92,6 +110,11 @@ func (o *Orchestrator) UploadFile( } task, err := o.CreateTask(ctx, bucketID, objID) + + metrics.CreatedProcessingTasksCounter. + WithLabelValues(kernel.AppName, strconv.FormatBool(err != nil)). + Inc() + if err != nil { err = fmt.Errorf("failed to create taskUC %s: %w", params.FilePath, err) span.SetStatus(codes.Error, err.Error()) @@ -102,7 +125,11 @@ func (o *Orchestrator) UploadFile( return task, nil } -func (o *Orchestrator) CreateTask(ctx Ctx, bucketID kernel.BucketID, objID kernel.ObjectID) (*taskDomain.Task, error) { +func (o *Orchestrator) CreateTask( + ctx kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, +) (*taskDomain.Task, error) { task := taskDomain.CreateNewTask(bucketID, objID) taskID := task.ID.String() @@ -112,7 +139,7 @@ func (o *Orchestrator) CreateTask(ctx Ctx, bucketID kernel.BucketID, objID kerne slog.String("file-path", objID), ) - ctx, span := telemetry.GlobalTracer.Start(ctx, "create-and-publish-task") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "create-and-publish-task") defer span.End() span.SetAttributes( @@ -141,10 +168,10 @@ func (o *Orchestrator) CreateTask(ctx Ctx, bucketID kernel.BucketID, objID kerne return task, nil } -func (o *Orchestrator) handleTask(ctx Ctx, task *taskDomain.Task) { +func (o *Orchestrator) handleTask(ctx kernel.Ctx, task *taskDomain.Task) { slog.Info("processing task event", slog.String("task-id", task.ID.String())) - ctx, span := telemetry.GlobalTracer.Start(ctx, "handle-task-from-queue") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "handle-task-from-queue") defer span.End() span.SetAttributes( @@ -170,8 +197,8 @@ func (o *Orchestrator) handleTask(ctx Ctx, task *taskDomain.Task) { slog.Info(msg, slog.String("task-id", task.ID.String())) } -func (o *Orchestrator) processTask(ctx Ctx, task *taskDomain.Task) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "task-processing") +func (o *Orchestrator) processTask(ctx kernel.Ctx, task *taskDomain.Task) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "task-processing") defer span.End() span.SetAttributes( diff --git a/internal/shared/kernel/context.go b/internal/shared/kernel/context.go new file mode 100644 index 0000000..0af0ebe --- /dev/null +++ b/internal/shared/kernel/context.go @@ -0,0 +1,7 @@ +package kernel + +import ( + "context" +) + +type Ctx context.Context diff --git a/internal/shared/kernel/ids.go b/internal/shared/kernel/ids.go index e805d83..9623e7b 100644 --- a/internal/shared/kernel/ids.go +++ b/internal/shared/kernel/ids.go @@ -1,6 +1,19 @@ package kernel -import "watchtower/internal/core/cloud/domain" +import "github.com/google/uuid" -type BucketID = domain.BucketID -type ObjectID = domain.ObjectID +// BucketID is a unique identifier for a storage bucket. +// Buckets are top-level containers that hold objects (files). +type BucketID = string + +// ObjectID is a unique identifier for an object within a bucket. +// Objects are the actual files stored in the bucket. +type ObjectID = string + +// MessageID is a unique identifier for a queue message using UUID v4. +// This is separate from TaskID as the same task might be queued multiple times. +type MessageID = uuid.UUID + +// TaskID is a unique identifier for a task using UUID v4. +// This ensures globally unique task identifiers across distributed systems. +type TaskID = uuid.UUID diff --git a/internal/shared/kernel/name.go b/internal/shared/kernel/name.go new file mode 100644 index 0000000..e38281b --- /dev/null +++ b/internal/shared/kernel/name.go @@ -0,0 +1,5 @@ +package kernel + +const ( + AppName = "watchtower" +) diff --git a/internal/shared/metrics/metrics.go b/internal/shared/metrics/metrics.go new file mode 100644 index 0000000..70ba149 --- /dev/null +++ b/internal/shared/metrics/metrics.go @@ -0,0 +1,75 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + RmqReconnectCounter *prometheus.CounterVec + UploadedFilesCounter *prometheus.CounterVec + CreatedProcessingTasksCounter *prometheus.CounterVec + OrchestratorProcessingCounter *prometheus.CounterVec + + OrchestratorProcessingDurationSeconds *prometheus.HistogramVec + RecognizerDurationSeconds *prometheus.HistogramVec + StoreProcessedDocumentDurationSeconds *prometheus.HistogramVec +) + +func init() { + RmqReconnectCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "watchtower_rmq_reconnect_total", + Help: "Total number of rmq reconnects", + }, + []string{"service", "is_failed"}, + ) + + UploadedFilesCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "watchtower_upload_files_total", + Help: "Total number of uploaded files to storage", + }, + []string{"service", "is_failed"}, + ) + + CreatedProcessingTasksCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "watchtower_created_tasks_total", + Help: "Total number of created tasks of processing", + }, + []string{"service", "is_failed"}, + ) + + OrchestratorProcessingCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "watchtower_orchestrator_processed_total", + Help: "Total processed documents into orchestrator", + }, + []string{"service", "status"}, + ) + + OrchestratorProcessingDurationSeconds = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "watchtower_orchestrator_processing_duration_seconds", + Help: "Latency of full document processing time in seconds", + }, + []string{"service", "status"}, + ) + + RecognizerDurationSeconds = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "watchtower_recognizer_duration_seconds", + Help: "Latency of recognizing text from document file", + }, + []string{"service", "is_failed"}, + ) + + StoreProcessedDocumentDurationSeconds = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "watchtower_store_document_duration_seconds", + Help: "Latency of storing processed document", + }, + []string{"service", "is_failed"}, + ) +} diff --git a/internal/shared/telemetry/config.go b/internal/shared/telemetry/config.go deleted file mode 100644 index dd59c26..0000000 --- a/internal/shared/telemetry/config.go +++ /dev/null @@ -1,17 +0,0 @@ -package telemetry - -type OtlpConfig struct { - Logger LoggerConfig `yaml:"logger"` - Tracer TracerConfig `yaml:"tracer"` -} - -type LoggerConfig struct { - Level string `mapstructure:"level"` - Address string `mapstructure:"address"` - EnableLoki bool `mapstructure:"enable_loki"` -} - -type TracerConfig struct { - Address string `mapstructure:"address"` - EnableJaeger bool `mapstructure:"enable_jaeger"` -} diff --git a/internal/shared/telemetry/logger.go b/internal/shared/telemetry/logger.go deleted file mode 100644 index 151e2e5..0000000 --- a/internal/shared/telemetry/logger.go +++ /dev/null @@ -1,38 +0,0 @@ -package telemetry - -import ( - "fmt" - "log/slog" - "time" - - slogloki "github.com/samber/slog-loki/v2" -) - -var ( - filterURI = []string{ - "/metrics", - "/swagger/*", - } -) - -type SlogLokiLogger struct { - Client *slog.Logger - FilterURI []string -} - -func InitLokiLogger(config LoggerConfig) SlogLokiLogger { - lokiConfig := slogloki.Option{ - Endpoint: fmt.Sprintf("%s/api/prom/push", config.Address), - Level: slog.LevelInfo, - BatchWait: time.Second * 5, - BatchEntriesNumber: 10, - } - - logger := slog.New(lokiConfig.NewLokiHandler()). - With("service_name", AppName). - With("service", AppName). - With("detected_level", config.Level). - With("level", config.Level) - - return SlogLokiLogger{Client: logger, FilterURI: filterURI} -} diff --git a/internal/shared/telemetry/tracer.go b/internal/shared/telemetry/tracer.go deleted file mode 100644 index 4facb40..0000000 --- a/internal/shared/telemetry/tracer.go +++ /dev/null @@ -1,66 +0,0 @@ -package telemetry - -import ( - "context" - "fmt" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/sdk/resource" - "go.opentelemetry.io/otel/trace" - - sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.37.0" -) - -const AppName = "watchtower" - -var ( - GlobalTracer trace.Tracer - TracePropagator = propagation.NewCompositeTextMapPropagator( - propagation.TraceContext{}, - propagation.Baggage{}, - ) -) - -func InitTracer(config TracerConfig) (trace.Tracer, error) { - sampler := sdktrace.AlwaysSample() - res, err := resource.Merge( - resource.Default(), - resource.NewWithAttributes( - semconv.SchemaURL, - semconv.TelemetrySDKLanguageGo, - semconv.ServiceNameKey.String(AppName), - ), - ) - if err != nil { - return nil, fmt.Errorf("failed to merge trace resources: %w", err) - } - - var traceOpts []sdktrace.TracerProviderOption - traceOpts = append(traceOpts, sdktrace.WithResource(res)) - traceOpts = append(traceOpts, sdktrace.WithSampler(sampler)) - - if config.EnableJaeger { - ctx := context.Background() - client := otlptracegrpc.NewClient( - otlptracegrpc.WithEndpoint(config.Address), - otlptracegrpc.WithInsecure(), - ) - - traceExporter, err := otlptrace.New(ctx, client) - if err != nil { - return nil, fmt.Errorf("failed to connect to otlp trace server: %w", err) - } - bsp := sdktrace.NewBatchSpanProcessor(traceExporter) - traceOpts = append(traceOpts, sdktrace.WithSpanProcessor(bsp)) - } - - tp := sdktrace.NewTracerProvider(traceOpts...) - otel.SetTextMapPropagator(TracePropagator) - GlobalTracer = tp.Tracer(AppName) - otel.SetTracerProvider(tp) - return GlobalTracer, nil -} diff --git a/internal/shared/utils/sender.go b/internal/shared/utils/sender.go index 5e4850a..9ca029a 100644 --- a/internal/shared/utils/sender.go +++ b/internal/shared/utils/sender.go @@ -2,12 +2,12 @@ package utils import ( "bytes" - "context" "fmt" "io" "net/http" "time" + "github.com/breadrock1/otlp-go/otlp" "github.com/labstack/echo/v4" "go.opentelemetry.io/otel" @@ -15,10 +15,10 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" - "watchtower/internal/shared/telemetry" + "watchtower/internal/shared/kernel" ) -func PUT(ctx context.Context, body *bytes.Buffer, url, mime string, timeout time.Duration) ([]byte, error) { +func PUT(ctx kernel.Ctx, body *bytes.Buffer, url, mime string, timeout time.Duration) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -29,7 +29,7 @@ func PUT(ctx context.Context, body *bytes.Buffer, url, mime string, timeout time return sendRequest(ctx, client, req) } -func POST(ctx context.Context, body *bytes.Buffer, url, mime string, timeout time.Duration) ([]byte, error) { +func POST(ctx kernel.Ctx, body *bytes.Buffer, url, mime string, timeout time.Duration) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -40,8 +40,8 @@ func POST(ctx context.Context, body *bytes.Buffer, url, mime string, timeout tim return sendRequest(ctx, client, req) } -func sendRequest(ctx context.Context, client *http.Client, req *http.Request) ([]byte, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "http-request") +func sendRequest(ctx kernel.Ctx, client *http.Client, req *http.Request) ([]byte, error) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "http-request") defer span.End() span.SetAttributes( @@ -50,6 +50,7 @@ func sendRequest(ctx context.Context, client *http.Client, req *http.Request) ([ ) injectSpanContext(ctx, req) + //nolint response, err := client.Do(req) if err != nil { err = fmt.Errorf("sending request error: %w", err) @@ -78,13 +79,13 @@ func sendRequest(ctx context.Context, client *http.Client, req *http.Request) ([ return respData, nil } -func extractSpanContext(ctx context.Context, resp *http.Response) context.Context { - propagator := telemetry.TracePropagator +func extractSpanContext(ctx kernel.Ctx, resp *http.Response) kernel.Ctx { + propagator := otlp_go.TracePropagator carrier := propagation.HeaderCarrier(resp.Header) return propagator.Extract(ctx, carrier) } -func injectSpanContext(ctx context.Context, req *http.Request) { +func injectSpanContext(ctx kernel.Ctx, req *http.Request) { propagator := otel.GetTextMapPropagator() carrier := propagation.HeaderCarrier(req.Header) propagator.Inject(ctx, carrier) diff --git a/internal/support/task/application/service/docstorage/docstorage.go b/internal/support/task/application/service/docstorage/docstorage.go index 34bdf5d..1a27bd2 100644 --- a/internal/support/task/application/service/docstorage/docstorage.go +++ b/internal/support/task/application/service/docstorage/docstorage.go @@ -1,7 +1,9 @@ package docstorage -import "context" +import ( + "watchtower/internal/shared/kernel" +) type IDocumentStorage interface { - StoreDocument(ctx context.Context, document *Document) (DocumentID, error) + StoreDocument(ctx kernel.Ctx, document *Document) (DocumentID, error) } diff --git a/internal/support/task/application/service/recognizer/recognizer.go b/internal/support/task/application/service/recognizer/recognizer.go index 6113274..a0da7e0 100644 --- a/internal/support/task/application/service/recognizer/recognizer.go +++ b/internal/support/task/application/service/recognizer/recognizer.go @@ -1,9 +1,9 @@ package recognizer import ( - "context" + "watchtower/internal/shared/kernel" ) type IRecognizer interface { - Recognize(ctx context.Context, params *RecognizeParams) (*Recognized, error) + Recognize(ctx kernel.Ctx, params *RecognizeParams) (*Recognized, error) } diff --git a/internal/support/task/application/usecase.go b/internal/support/task/application/usecase.go index 032e309..cb76d6d 100644 --- a/internal/support/task/application/usecase.go +++ b/internal/support/task/application/usecase.go @@ -2,24 +2,24 @@ package application import ( "bytes" - "context" "fmt" "log/slog" "path" + "strconv" + "time" + "github.com/breadrock1/otlp-go/otlp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "watchtower/internal/shared/kernel" - "watchtower/internal/shared/telemetry" + "watchtower/internal/shared/metrics" "watchtower/internal/support/task/application/mapping" "watchtower/internal/support/task/application/service/docstorage" "watchtower/internal/support/task/application/service/recognizer" "watchtower/internal/support/task/domain" ) -type Ctx context.Context - type TaskUseCase struct { taskStorage domain.ITaskManager taskQueue domain.ITaskQueue @@ -41,8 +41,8 @@ func NewTaskUseCase( } } -func (p *TaskUseCase) GetBucketTasks(ctx Ctx, bucketID kernel.BucketID) ([]*domain.Task, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "get-all-bucket-tasks") +func (p *TaskUseCase) GetBucketTasks(ctx kernel.Ctx, bucketID kernel.BucketID) ([]*domain.Task, error) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "get-all-bucket-tasks") defer span.End() span.SetAttributes(attribute.String("bucket", bucketID)) @@ -58,8 +58,8 @@ func (p *TaskUseCase) GetBucketTasks(ctx Ctx, bucketID kernel.BucketID) ([]*doma return allTasks, nil } -func (p *TaskUseCase) GetTask(ctx Ctx, bucketID kernel.BucketID, taskID domain.TaskID) (*domain.Task, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "get-task-by-id") +func (p *TaskUseCase) GetTask(ctx kernel.Ctx, bucketID kernel.BucketID, taskID kernel.TaskID) (*domain.Task, error) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "get-task-by-id") defer span.End() span.SetAttributes( @@ -69,7 +69,7 @@ func (p *TaskUseCase) GetTask(ctx Ctx, bucketID kernel.BucketID, taskID domain.T task, err := p.taskStorage.GetTask(ctx, bucketID, taskID) if err != nil { - err = fmt.Errorf("ftask manager error: %w", err) + err = fmt.Errorf("task manager error: %w", err) span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return nil, err @@ -78,8 +78,8 @@ func (p *TaskUseCase) GetTask(ctx Ctx, bucketID kernel.BucketID, taskID domain.T return task, nil } -func (p *TaskUseCase) UpdateTaskStatus(ctx Ctx, task *domain.Task) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "update-task-status") +func (p *TaskUseCase) UpdateTaskStatus(ctx kernel.Ctx, task *domain.Task) { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "update-task-status") defer span.End() span.SetAttributes( @@ -97,8 +97,8 @@ func (p *TaskUseCase) UpdateTaskStatus(ctx Ctx, task *domain.Task) { } } -func (p *TaskUseCase) IsTaskAlreadyExists(ctx Ctx, task *domain.Task) bool { - ctx, span := telemetry.GlobalTracer.Start(ctx, "check-task-already-created") +func (p *TaskUseCase) IsTaskAlreadyExists(ctx kernel.Ctx, task *domain.Task) bool { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "check-task-already-created") defer span.End() span.SetAttributes( @@ -135,7 +135,7 @@ func (p *TaskUseCase) IsTaskAlreadyExists(ctx Ctx, task *domain.Task) bool { } } -func (p *TaskUseCase) PublishTaskToQueue(ctx Ctx, task *domain.Task) error { +func (p *TaskUseCase) PublishTaskToQueue(ctx kernel.Ctx, task *domain.Task) error { msg := mapping.MessageFromTask(task) err := p.taskQueue.Publish(ctx, msg) return err @@ -146,11 +146,11 @@ func (p *TaskUseCase) GetConsumerChannel() chan domain.Message { } func (p *TaskUseCase) Recognize( - ctx Ctx, + ctx kernel.Ctx, task *domain.Task, fileData *bytes.Buffer, ) (*recognizer.Recognized, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "recognize-object-data") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "recognize-object-data") defer span.End() span.SetAttributes( @@ -164,8 +164,16 @@ func (p *TaskUseCase) Recognize( FileData: fileData, } + instant := time.Now() + // TODO: impled retry pattern recData, err := p.recognizer.Recognize(ctx, inputFile) + + elapsedTime := time.Since(instant) + metrics.RecognizerDurationSeconds. + WithLabelValues(kernel.AppName, strconv.FormatBool(err != nil)). + Observe(elapsedTime.Seconds()) + if err != nil { task.SetStatusAndText(domain.Failed, "failed to recognize file") err = fmt.Errorf("failed to recognize file %s: %w", task.ID, err) @@ -178,11 +186,11 @@ func (p *TaskUseCase) Recognize( } func (p *TaskUseCase) StoreDocument( - ctx Ctx, + ctx kernel.Ctx, task *domain.Task, recData *recognizer.Recognized, ) (docstorage.DocumentID, error) { - ctx, span := telemetry.GlobalTracer.Start(ctx, "store-document-to-index") + ctx, span := otlp_go.GlobalTracer.Start(ctx, "store-document-to-index") defer span.End() span.SetAttributes( @@ -201,7 +209,15 @@ func (p *TaskUseCase) StoreDocument( ModifiedAt: task.ModifiedAt, } + instant := time.Now() + docID, err := p.docStorage.StoreDocument(ctx, doc) + + elapsedTime := time.Since(instant) + metrics.StoreProcessedDocumentDurationSeconds. + WithLabelValues(kernel.AppName, strconv.FormatBool(err != nil)). + Observe(elapsedTime.Seconds()) + if err != nil { err = fmt.Errorf("failed to store document: %w", err) span.SetStatus(codes.Error, err.Error()) diff --git a/internal/support/task/domain/error.go b/internal/support/task/domain/error.go new file mode 100644 index 0000000..9123f06 --- /dev/null +++ b/internal/support/task/domain/error.go @@ -0,0 +1,9 @@ +package domain + +import "errors" + +var ( + ErrExecution = errors.New("execution error") + ErrTaskNotFound = errors.New("task not found") + ErrInvalidTaskData = errors.New("invalid task data") +) diff --git a/internal/support/task/domain/message.go b/internal/support/task/domain/message.go index 15c4d66..e74f327 100644 --- a/internal/support/task/domain/message.go +++ b/internal/support/task/domain/message.go @@ -1,15 +1,24 @@ package domain import ( - "context" - - "github.com/google/uuid" + "watchtower/internal/shared/kernel" ) -type MessageID = uuid.UUID - +// Message represents a task wrapped for queue transport. +// It includes context for distributed tracing and the actual task payload. type Message struct { - Ctx context.Context - EventId MessageID - Body Task + // Ctx carries cancellation signals and deadlines across service boundaries + Ctx kernel.Ctx + + // EventId uniquely identifies this specific message in the queue + EventId kernel.MessageID + + // Body contains the actual task to be processed + Body Task + + // Metadata holds additional routing and tracing information + Metadata map[string]string + + // DeliveryAttempt counts how many times this message has been delivered + DeliveryAttempt int } diff --git a/internal/support/task/domain/queue.go b/internal/support/task/domain/queue.go index 8773b6f..86ad8d2 100644 --- a/internal/support/task/domain/queue.go +++ b/internal/support/task/domain/queue.go @@ -1,20 +1,113 @@ package domain import ( - "context" + "watchtower/internal/shared/kernel" ) +// ITaskQueue defines the complete interface for task queue operations. +// It combines publishing and consuming capabilities for a complete +// producer-consumer pattern implementation. type ITaskQueue interface { IConsumer IPublisher } +// IPublisher defines operations for publishing tasks to the queue. +// Publishers are responsible for enqueuing tasks for asynchronous processing. type IPublisher interface { - Publish(ctx context.Context, msg Message) error + // Publish sends a task message to the queue for asynchronous processing. + // The message is persisted in the queue and will be delivered to a consumer. + // + // Parameters: + // - ctx: Context for cancellation and timeout + // - msg: Complete message containing the task and metadata + // + // Returns: + // - error: ErrQueueUnavailable if queue service is down, + // ErrInvalidMessage if message validation fails, + // ErrPublishTimeout if operation exceeds deadline, + // or other queue-specific errors + // + // Example: + // task := Task{ + // ID: uuid.New(), + // BucketID: "input-bucket", + // ObjectID: "data/file.json", + // Status: Received, + // CreatedAt: time.Now(), + // } + // + // msg := Message{ + // EventId: uuid.New(), + // Body: task, + // Metadata: map[string]string{"source": "api"}, + // } + // + // err := publisher.Publish(ctx, msg) + // if err != nil { + // log.Printf("Failed to publish task: %v", err) + // } + Publish(ctx kernel.Ctx, msg Message) error } +// IConsumer defines operations for consuming tasks from the queue. +// Consumers process tasks asynchronously and manage the consumption lifecycle. type IConsumer interface { + // GetConsumerChannel returns a read-only channel for receiving messages. + // This channel should be used in a select statement or range loop to + // process incoming tasks. + // + // Returns: + // - chan Message: Channel that delivers messages as they arrive + // + // Example: + // msgChan := consumer.GetConsumerChannel() + // for msg := range msgChan { + // go processMessage(msg) + // } GetConsumerChannel() chan Message - StartConsuming(ctx context.Context) error - StopConsuming(ctx context.Context) error + + // StartConsuming begins the message consumption process. + // This method typically connects to the queue service and begins + // delivering messages to the consumer channel. + // + // Parameters: + // - ctx: Context for controlling the consumption lifecycle + // + // Returns: + // - error: ErrConsumerAlreadyStarted if already consuming, + // ErrQueueUnavailable if cannot connect to queue, + // or other queue-specific errors + // + // Example: + // ctx, cancel := context.WithCancel(context.Background()) + // defer cancel() + // + // go func() { + // if err := consumer.StartConsuming(ctx); err != nil { + // log.Printf("Consumer failed: %v", err) + // } + // }() + StartConsuming(ctx kernel.Ctx) error + + // StopConsuming gracefully stops the message consumption process. + // It should complete any in-progress message handling before stopping. + // + // Parameters: + // - ctx: Context for timeout control during shutdown + // + // Returns: + // - error: ErrConsumerNotStarted if not consuming, + // ErrShutdownTimeout if graceful shutdown times out, + // or other queue-specific errors + // + // Example: + // // Graceful shutdown + // shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + // defer cancel() + // + // if err := consumer.StopConsuming(shutdownCtx); err != nil { + // log.Printf("Force shutdown: %v", err) + // } + StopConsuming(ctx kernel.Ctx) error } diff --git a/internal/support/task/domain/storage.go b/internal/support/task/domain/storage.go index 8b214a4..f7c2c71 100644 --- a/internal/support/task/domain/storage.go +++ b/internal/support/task/domain/storage.go @@ -1,17 +1,82 @@ package domain import ( - "context" - - "watchtower/internal/core/cloud/domain" + "watchtower/internal/shared/kernel" ) +// ITaskStorage defines the interface for persistent task storage. +// This is used to track task state independently from the message queue. type ITaskStorage interface { ITaskManager } +// ITaskManager defines operations for managing task lifecycle in persistent storage. +// Tasks are stored independently of the queue to maintain state across system restarts +// and provide audit capabilities. type ITaskManager interface { - GetTask(ctx context.Context, bucketID domain.BucketID, taskID TaskID) (*Task, error) - GetAllBucketTasks(ctx context.Context, bucketID domain.BucketID) ([]*Task, error) - UpdateTask(ctx context.Context, task *Task) error + // GetTask retrieves a task by its bucket and task IDs. + // This is useful for checking task status or retrieving results. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket containing the task's input + // - taskID: Unique identifier of the task + // + // Returns: + // - *Task: Complete task information including current status + // - error: ErrExecution if returned operation error, + // ErrTaskNotFound if task not found, + // ErrInvalidTaskData if update would violate constraints, + // or other storage errors + // + // Example: + // task, err := storage.GetTask(ctx, "input-bucket", taskID) + // if err == nil { + // fmt.Printf("Task %s status: %s\n", task.ID, task.Status) + // } + GetTask(ctx kernel.Ctx, bucketID kernel.BucketID, taskID kernel.TaskID) (*Task, error) + + // GetAllBucketTasks retrieves all tasks associated with a specific bucket. + // This is useful for monitoring and batch operations. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - bucketID: ID of the bucket to get tasks for + // + // Returns: + // - []*Task: Slice of all tasks for the bucket, ordered by creation time + // - error: ErrExecution if returned operation error, + // ErrInvalidTaskData if update would violate constraints, + // or other storage errors + // + // Example: + // tasks, err := storage.GetAllBucketTasks(ctx, "input-bucket") + // for _, task := range tasks { + // fmt.Printf("Task %s: %s\n", task.ID, task.Status) + // } + GetAllBucketTasks(ctx kernel.Ctx, bucketID kernel.BucketID) ([]*Task, error) + + // UpdateTask updates an existing task's status and metadata. + // This is called as tasks progress through their lifecycle. + // + // Parameters: + // - kernel.Ctx: Context for cancellation and timeout + // - task: Complete task object with updated fields + // + // Returns: + // - error: ErrExecution if returned operation error, + // ErrTaskNotFound if task not found, + // ErrInvalidTaskData if update would violate constraints, + // or other storage errors + // + // Example: + // task.Status = Processing + // task.StatusText = "Starting processing..." + // task.ModifiedAt = time.Now() + // + // err := storage.UpdateTask(ctx, task) + // if err != nil { + // log.Printf("Failed to update task: %v", err) + // } + UpdateTask(ctx kernel.Ctx, task *Task) error } diff --git a/internal/support/task/domain/task.go b/internal/support/task/domain/task.go index f54ac4e..ce71f82 100644 --- a/internal/support/task/domain/task.go +++ b/internal/support/task/domain/task.go @@ -6,7 +6,8 @@ import ( "time" "github.com/google/uuid" - "watchtower/internal/core/cloud/domain" + + "watchtower/internal/shared/kernel" ) const ( @@ -14,30 +15,72 @@ const ( ProcessingStatusText = "processing" ) +// TaskStatus represents the current state of a task in its lifecycle. +// The status follows a typical workflow: Received -> Pending -> Processing -> Successful, +// with Failed as a terminal error state. type TaskStatus int const ( - Failed TaskStatus = iota - 1 - Received - Pending - Processing - Successful -) + // Failed indicates the task processing encountered an error and could not complete. + // This is a terminal state. + Failed TaskStatus = iota - 1 // -1 + + // Received indicates the task has been accepted by the queue system + // but not yet scheduled for processing. + Received // 0 + + // Pending indicates the task is waiting to be processed by a worker. + Pending // 1 -type TaskID = uuid.UUID + // Processing indicates the task is currently being executed by a worker. + Processing // 2 + // Successful indicates the task completed successfully. + // This is a terminal state. + Successful // 3 +) + +// Task represents a unit of work to be processed asynchronously. +// It contains all necessary information for processing and tracks the task's +// lifecycle from creation to completion or failure. type Task struct { - ID TaskID - BucketID domain.BucketID - ObjectID domain.ObjectID + // ID uniquely identifies the task across the entire system + ID kernel.TaskID + + // BucketID identifies which storage bucket contains the input data + BucketID kernel.BucketID + + // ObjectID identifies the specific object in the bucket to process + ObjectID kernel.ObjectID + + // ObjectDataSize is the size of the input data in bytes, + // useful for progress tracking and resource estimation ObjectDataSize int - StatusText string - Status TaskStatus - CreatedAt time.Time - ModifiedAt time.Time + + // StatusText provides additional context about the current status, + // such as error messages for failed tasks or progress for processing tasks + StatusText string + + // Status indicates the current state in the task lifecycle + Status TaskStatus + + // CreatedAt is the timestamp when the task was initially created + CreatedAt time.Time + + // ModifiedAt is the timestamp of the last status update + ModifiedAt time.Time + + // RetryCount indicates how many times this task has been retried + RetryCount int + + // MaxRetries specifies the maximum number of retry attempts + MaxRetries int + + // ProcessingDuration tracks how long the task took to process (when completed) + ProcessingDuration time.Duration } -func CreateNewTask(bucketID domain.BucketID, objectID domain.ObjectID) *Task { +func CreateNewTask(bucketID kernel.BucketID, objectID kernel.ObjectID) *Task { // TODO: Disabled for TechDebt // taskID := GenerateUniqID(form.ID, form.FilePath) taskID := GenerateTaskID() diff --git a/internal/support/task/infrastructure/docparser/docparser.go b/internal/support/task/infrastructure/docparser/docparser.go index f50ad63..64b20bb 100644 --- a/internal/support/task/infrastructure/docparser/docparser.go +++ b/internal/support/task/infrastructure/docparser/docparser.go @@ -2,19 +2,17 @@ package docparser import ( "bytes" - "context" "encoding/json" "fmt" "mime/multipart" "time" + "watchtower/internal/shared/kernel" "watchtower/internal/shared/utils" "watchtower/internal/support/task/application/service/recognizer" ) -type Ctx = context.Context - -const RecognitionURL = "/parser/parse/text" +const RecognitionURL = "/api/v1/parser/parse/text" type DocParser struct { config Config @@ -24,7 +22,7 @@ func New(config Config) recognizer.IRecognizer { return &DocParser{config} } -func (dc *DocParser) Recognize(ctx Ctx, params *recognizer.RecognizeParams) (*recognizer.Recognized, error) { +func (dc *DocParser) Recognize(ctx kernel.Ctx, params *recognizer.RecognizeParams) (*recognizer.Recognized, error) { var buf bytes.Buffer mpw := multipart.NewWriter(&buf) diff --git a/internal/support/task/infrastructure/docsearch/docsearch.go b/internal/support/task/infrastructure/docsearch/docsearch.go index d2e5500..f351e79 100644 --- a/internal/support/task/infrastructure/docsearch/docsearch.go +++ b/internal/support/task/infrastructure/docsearch/docsearch.go @@ -2,13 +2,13 @@ package docsearch import ( "bytes" - "context" "encoding/json" "fmt" "log/slog" "strings" "time" + "watchtower/internal/shared/kernel" "watchtower/internal/shared/utils" "watchtower/internal/support/task/application/service/docstorage" ) @@ -25,7 +25,7 @@ func New(config Config) docstorage.IDocumentStorage { } } -func (ds *DocSearch) StoreDocument(ctx context.Context, doc *docstorage.Document) (docstorage.DocumentID, error) { +func (ds *DocSearch) StoreDocument(ctx kernel.Ctx, doc *docstorage.Document) (docstorage.DocumentID, error) { index := doc.Index storeDoc := StoreDocumentForm{ FileName: doc.Name, @@ -42,9 +42,10 @@ func (ds *DocSearch) StoreDocument(ctx context.Context, doc *docstorage.Document return "", err } - buildURL := strings.Builder{} + urlPath := fmt.Sprintf("/api/v1/storage/%s/create?force=true", index) + buildURL := &strings.Builder{} buildURL.WriteString(ds.config.Address) - buildURL.WriteString(fmt.Sprintf("/storage/%s/create?force=true", index)) + buildURL.WriteString(urlPath) targetURL := buildURL.String() slog.Debug("storing document to index", diff --git a/internal/support/task/infrastructure/redis/redis.go b/internal/support/task/infrastructure/redis/redis.go index b527c50..2fd1888 100644 --- a/internal/support/task/infrastructure/redis/redis.go +++ b/internal/support/task/infrastructure/redis/redis.go @@ -1,17 +1,15 @@ package redis import ( - "context" "encoding/json" "fmt" "log/slog" "time" + "github.com/redis/go-redis/v9" + "watchtower/internal/shared/kernel" - "watchtower/internal/shared/telemetry" "watchtower/internal/support/task/domain" - - "github.com/redis/go-redis/v9" ) type RedisClient struct { @@ -22,13 +20,16 @@ type RedisClient struct { func New(config Config) domain.ITaskStorage { redisOpts := &redis.Options{Addr: config.Address} conn := redis.NewClient(redisOpts) + + slog.Info("redis connection established", slog.String("address", config.Address)) + return &RedisClient{ config: config, rsConn: conn, } } -func (rs *RedisClient) GetAllBucketTasks(ctx context.Context, bucketID kernel.BucketID) ([]*domain.Task, error) { +func (rs *RedisClient) GetAllBucketTasks(ctx kernel.Ctx, bucketID kernel.BucketID) ([]*domain.Task, error) { key := rs.generateUniqID(bucketID, "*") status := rs.rsConn.Scan(ctx, 0, key, -1) if status.Err() != nil { @@ -39,6 +40,7 @@ func (rs *RedisClient) GetAllBucketTasks(ctx context.Context, bucketID kernel.Bu tasks := make([]*domain.Task, len(rKeys)) for index, rKey := range rKeys { cmd := rs.rsConn.Get(ctx, rKey) + data, err := cmd.Bytes() if err != nil { slog.Warn("failed to get task", slog.String("err", err.Error())) @@ -64,51 +66,51 @@ func (rs *RedisClient) GetAllBucketTasks(ctx context.Context, bucketID kernel.Bu } func (rs *RedisClient) GetTask( - ctx context.Context, + ctx kernel.Ctx, bucketID kernel.BucketID, - taskID domain.TaskID, + taskID kernel.TaskID, ) (*domain.Task, error) { key := rs.generateUniqID(bucketID, taskID.String()) cmd := rs.rsConn.Get(ctx, key) if cmd.Err() != nil { - return nil, fmt.Errorf("redis error: %w", cmd.Err()) + return nil, fmt.Errorf("redis error: %w: %w", domain.ErrExecution, cmd.Err()) } data, err := cmd.Bytes() if err != nil { - return nil, fmt.Errorf("read bytes data error: %w", err) + return nil, fmt.Errorf("redis payload error: %w: %w", domain.ErrExecution, cmd.Err()) } - value := &RedisValue{} + var value *RedisValue if err = json.Unmarshal(data, &value); err != nil { - return nil, fmt.Errorf("deserialize error: %w", err) + return nil, fmt.Errorf("deserialize error: %w: %w", domain.ErrInvalidTaskData, err) } taskEvent, err := value.ConvertToTask() if err != nil { - return nil, fmt.Errorf("task validation error: %w", err) + return nil, fmt.Errorf("task validation error: %w: %w", domain.ErrInvalidTaskData, err) } return taskEvent, nil } -func (rs *RedisClient) UpdateTask(ctx context.Context, task *domain.Task) error { +func (rs *RedisClient) UpdateTask(ctx kernel.Ctx, task *domain.Task) error { key := rs.generateUniqID(task.BucketID, task.ID.String()) value := ConvertFromTaskEvent(task) jsonData, err := json.Marshal(value) if err != nil { - return fmt.Errorf("serialize error: %w", err) + return fmt.Errorf("serialize error: %w: %w", domain.ErrInvalidTaskData, err) } status := rs.rsConn.Set(ctx, key, jsonData, rs.config.Expired*time.Second) if status.Err() != nil { - return fmt.Errorf("redis error: %w", status.Err()) + return fmt.Errorf("redis error: %w: %w", domain.ErrExecution, status.Err()) } return nil } func (rs *RedisClient) generateUniqID(bucketID kernel.BucketID, taskID string) string { - return fmt.Sprintf("%s:%s:%s", telemetry.AppName, bucketID, taskID) + return fmt.Sprintf("%s:%s:%s", kernel.AppName, bucketID, taskID) } diff --git a/internal/support/task/infrastructure/rmq/dto.go b/internal/support/task/infrastructure/rmq/dto.go index e088f1b..fa33d7a 100644 --- a/internal/support/task/infrastructure/rmq/dto.go +++ b/internal/support/task/infrastructure/rmq/dto.go @@ -1,14 +1,14 @@ package rmq import ( - "context" - "github.com/google/uuid" + + "watchtower/internal/shared/kernel" "watchtower/internal/support/task/domain" ) type Message struct { - Ctx context.Context + Ctx kernel.Ctx EventId uuid.UUID `json:"event_id"` Body domain.Task `json:"body"` } diff --git a/internal/support/task/infrastructure/rmq/rmq.go b/internal/support/task/infrastructure/rmq/rmq.go index fbd5c28..c69a35d 100644 --- a/internal/support/task/infrastructure/rmq/rmq.go +++ b/internal/support/task/infrastructure/rmq/rmq.go @@ -5,14 +5,17 @@ import ( "encoding/json" "fmt" "log/slog" + "strconv" "time" + "github.com/breadrock1/otlp-go/otlp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" - "watchtower/internal/shared/telemetry" + "watchtower/internal/shared/kernel" + "watchtower/internal/shared/metrics" "watchtower/internal/support/task/domain" amqp "github.com/rabbitmq/amqp091-go" @@ -22,7 +25,6 @@ const ConsumerName = "watchtower-consumer" type RabbitMQClient struct { redirect chan domain.Message - done chan error config Config conn *amqp.Connection @@ -47,9 +49,10 @@ func New(config Config) (domain.ITaskQueue, error) { return nil, fmt.Errorf("failed to create rmq channel: %w", err) } + slog.Info("rmq connection established", slog.String("address", config.Address)) + rmqClient = RabbitMQClient{ make(chan domain.Message), - make(chan error), config, conn, rmqCh, @@ -62,8 +65,8 @@ func (r *RabbitMQClient) GetConsumerChannel() chan domain.Message { return r.redirect } -func (r *RabbitMQClient) Publish(ctx context.Context, msg domain.Message) error { - ctx, span := telemetry.GlobalTracer.Start(ctx, "rmq-publish") +func (r *RabbitMQClient) Publish(ctx kernel.Ctx, msg domain.Message) error { + ctx, span := otlp_go.GlobalTracer.Start(ctx, "rmq-publish") defer span.End() headers := injectSpanContextToHeaders(ctx) @@ -104,8 +107,8 @@ func (r *RabbitMQClient) Publish(ctx context.Context, msg domain.Message) error return nil } -func (r *RabbitMQClient) StartConsuming(_ context.Context) error { - go r.handleReconnect() +func (r *RabbitMQClient) StartConsuming(ctx kernel.Ctx) error { + go r.handleReconnect(ctx) deliveries, err := r.channel.Consume( r.config.QueueName, // name @@ -121,12 +124,12 @@ func (r *RabbitMQClient) StartConsuming(_ context.Context) error { return fmt.Errorf("rmq: consume error: %w", err) } - go r.handleMessage(deliveries, r.done) + go r.handleMessage(ctx, deliveries) return nil } -func (r *RabbitMQClient) StopConsuming(_ context.Context) error { +func (r *RabbitMQClient) StopConsuming(_ kernel.Ctx) error { if err := r.channel.Cancel(ConsumerName, true); err != nil { return fmt.Errorf("rmq: consumer cancel failed: %w", err) } @@ -135,46 +138,57 @@ func (r *RabbitMQClient) StopConsuming(_ context.Context) error { return fmt.Errorf("rmq: close connection failed: %w", err) } - // wait for handleMessage() to exit - return <-r.done + return nil } -func (r *RabbitMQClient) handleMessage(deliveries <-chan amqp.Delivery, done chan error) { - cleanup := func() { - slog.Warn("rmq: deliveries channel closed") - done <- nil - } +func (r *RabbitMQClient) handleMessage(ctx kernel.Ctx, deliveries <-chan amqp.Delivery) { + slog.Info("launching rmq consumer") - defer cleanup() + for { + select { + case <-ctx.Done(): + if err := r.StopConsuming(ctx); err != nil { + slog.Error("failed to stop rmq consuming", slog.String("err", err.Error())) + return + } - for delMsg := range deliveries { - ctx := extractSpanContextFromHeaders(delMsg.Headers) - span := trace.SpanFromContext(ctx) + slog.Info("rmq deliveries channel has been closed") - consumeMsg := &Message{} - err := json.Unmarshal(delMsg.Body, consumeMsg) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - slog.Error("rmq: failed while deserialize msg", slog.String("err", err.Error())) - continue - } + return - span.SetName("rmq-consume") - span.SetAttributes(attribute.String("task-id", consumeMsg.EventId.String())) + case delMsg, ok := <-deliveries: + if !ok { + continue + } - consumeMsg.Ctx = ctx - msg := consumeMsg.ToMessage() + spanCtx := extractSpanContextFromHeaders(delMsg.Headers) + span := trace.SpanFromContext(spanCtx) - r.redirect <- *msg - span.End() + consumeMsg := &Message{} + err := json.Unmarshal(delMsg.Body, consumeMsg) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + slog.Error("rmq: failed while deserialize msg", slog.String("err", err.Error())) + continue + } + + span.SetName("rmq-consume") + span.SetAttributes(attribute.String("task-id", consumeMsg.EventId.String())) + + consumeMsg.Ctx = spanCtx + msg := consumeMsg.ToMessage() + + r.redirect <- *msg + span.End() + } } } -func (r *RabbitMQClient) handleReconnect() { +func (r *RabbitMQClient) handleReconnect(ctx kernel.Ctx) { for { select { - case <-r.done: + case <-ctx.Done(): return case <-r.conn.NotifyClose(make(chan *amqp.Error)): @@ -203,10 +217,19 @@ func (r *RabbitMQClient) handleReconnect() { } slog.Error("rmq: failed to create channel", slog.String("err", err.Error())) + reconnectDelay = reconnectCounter * reconnectCounter time.Sleep(time.Duration(reconnectDelay) * time.Second) + + metrics.RmqReconnectCounter. + WithLabelValues(kernel.AppName, strconv.FormatBool(err != nil)). + Inc() } + metrics.RmqReconnectCounter. + WithLabelValues(kernel.AppName, strconv.FormatBool(err != nil)). + Inc() + if err != nil { slog.Error("rmq: failed to restore connection", slog.String("err", err.Error())) return @@ -215,9 +238,9 @@ func (r *RabbitMQClient) handleReconnect() { } } -func injectSpanContextToHeaders(ctx context.Context) amqp.Table { +func injectSpanContextToHeaders(ctx kernel.Ctx) amqp.Table { carrier := propagation.HeaderCarrier{} - telemetry.TracePropagator.Inject(ctx, carrier) + otlp_go.TracePropagator.Inject(ctx, carrier) span := trace.SpanFromContext(ctx) sCtx := span.SpanContext() @@ -231,7 +254,7 @@ func injectSpanContextToHeaders(ctx context.Context) amqp.Table { return headers } -func extractSpanContextFromHeaders(headers amqp.Table) context.Context { +func extractSpanContextFromHeaders(headers amqp.Table) kernel.Ctx { ctx := context.Background() if headers == nil { return ctx diff --git a/tests/common/env_app_server.go b/tests/common/env_app_server.go new file mode 100644 index 0000000..3673844 --- /dev/null +++ b/tests/common/env_app_server.go @@ -0,0 +1,42 @@ +package common + +import ( + "watchtower/cmd" + "watchtower/cmd/watchtower/httpserver" + "watchtower/internal/process" + "watchtower/tests/common/mocks" + + cloudApp "watchtower/internal/core/cloud/application" + taskApp "watchtower/internal/support/task/application" +) + +type TestAppServerEnvironment struct { + ObjectStorage *mocks.MockObjectStorage + TaskStorage *mocks.MockTaskStorage + TaskQueue *mocks.MockTaskQueue + DocStorage *mocks.MockDocStorage + Recognizer *mocks.MockRecognizer +} + +func InitTestAppEnvironment() *TestAppServerEnvironment { + objectStorage := new(mocks.MockObjectStorage) + taskStorage := new(mocks.MockTaskStorage) + taskQueue := new(mocks.MockTaskQueue) + recognizer := new(mocks.MockRecognizer) + docStorage := new(mocks.MockDocStorage) + return &TestAppServerEnvironment{ + ObjectStorage: objectStorage, + TaskStorage: taskStorage, + TaskQueue: taskQueue, + DocStorage: docStorage, + Recognizer: recognizer, + } +} + +func (e *TestAppServerEnvironment) BuildAppServer(servConfig *cmd.Config) (*httpserver.Server, error) { + storageUseCase := cloudApp.NewStorageUseCase(e.ObjectStorage) + taskUseCase := taskApp.NewTaskUseCase(e.TaskStorage, e.TaskQueue, e.Recognizer, e.DocStorage) + orchestrator := process.NewOrchestrator(servConfig.Orchestrator, storageUseCase, taskUseCase) + appServer := httpserver.SetupServer(servConfig.Otlp, orchestrator) + return appServer, nil +} diff --git a/tests/common/mocks/docstorage.go b/tests/common/mocks/docstorage.go index b0f30a9..ebb66c2 100644 --- a/tests/common/mocks/docstorage.go +++ b/tests/common/mocks/docstorage.go @@ -1,7 +1,7 @@ package mocks import ( - "context" + "watchtower/internal/shared/kernel" "watchtower/internal/support/task/application/service/docstorage" @@ -12,7 +12,7 @@ type MockDocStorage struct { mock.Mock } -func (m *MockDocStorage) StoreDocument(_ context.Context, doc *docstorage.Document) (docstorage.DocumentID, error) { +func (m *MockDocStorage) StoreDocument(_ kernel.Ctx, doc *docstorage.Document) (docstorage.DocumentID, error) { args := m.Called(doc) return args.Get(0).(string), args.Error(1) } diff --git a/tests/common/mocks/object_storage.go b/tests/common/mocks/object_storage.go new file mode 100644 index 0000000..58369d9 --- /dev/null +++ b/tests/common/mocks/object_storage.go @@ -0,0 +1,106 @@ +package mocks + +import ( + "net/url" + + "github.com/stretchr/testify/mock" + + "watchtower/internal/core/cloud/domain" + "watchtower/internal/shared/kernel" +) + +type MockObjectStorage struct { + mock.Mock +} + +func (m *MockObjectStorage) GetAllBuckets(_ kernel.Ctx) ([]domain.Bucket, error) { + args := m.Called() + return args.Get(0).([]domain.Bucket), args.Error(1) +} + +func (m *MockObjectStorage) IsBucketExist(_ kernel.Ctx, bucketID kernel.BucketID) (bool, error) { + args := m.Called(bucketID) + return args.Bool(0), args.Error(1) +} + +func (m *MockObjectStorage) CreateBucket(_ kernel.Ctx, bucketID kernel.BucketID) error { + args := m.Called(bucketID) + return args.Error(0) +} + +func (m *MockObjectStorage) DeleteBucket(_ kernel.Ctx, bucketID kernel.BucketID) error { + args := m.Called(bucketID) + return args.Error(0) +} + +func (m *MockObjectStorage) GetObjectInfo( + _ kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, +) (domain.Object, error) { + args := m.Called(bucketID, objID) + return args.Get(0).(domain.Object), args.Error(1) +} + +func (m *MockObjectStorage) GetObjectData( + _ kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, +) (domain.ObjectData, error) { + args := m.Called(bucketID, objID) + return args.Get(0).(domain.ObjectData), args.Error(1) +} + +func (m *MockObjectStorage) StoreObject( + _ kernel.Ctx, + bucketID kernel.BucketID, + params *domain.UploadObjectParams, +) (kernel.ObjectID, error) { + args := m.Called(bucketID, params) + return args.Get(0).(kernel.ObjectID), args.Error(1) +} + +func (m *MockObjectStorage) CopyObject( + _ kernel.Ctx, + bucketID kernel.BucketID, + params *domain.CopyObjectParams, +) error { + args := m.Called(bucketID, params) + return args.Error(0) +} + +func (m *MockObjectStorage) DeleteObject( + _ kernel.Ctx, + bucketID kernel.BucketID, + objID kernel.ObjectID, +) error { + args := m.Called(bucketID, objID) + return args.Error(0) +} + +func (m *MockObjectStorage) DeleteObjects( + _ kernel.Ctx, + bucketID kernel.BucketID, + prefix string, +) error { + args := m.Called(bucketID, prefix) + return args.Error(1) +} + +func (m *MockObjectStorage) GetBucketObjects( + _ kernel.Ctx, + bucketID kernel.BucketID, + params *domain.GetObjectsParams, +) ([]domain.Object, error) { + args := m.Called(bucketID, params) + return args.Get(0).([]domain.Object), args.Error(1) +} + +func (m *MockObjectStorage) GenShareURL( + _ kernel.Ctx, + bucketID kernel.BucketID, + params *domain.ShareObjectParams, +) (*url.URL, error) { + args := m.Called(bucketID, params) + return args.Get(0).(*url.URL), args.Error(1) +} diff --git a/tests/common/mocks/recognizer.go b/tests/common/mocks/recognizer.go index 7da3520..1f89764 100644 --- a/tests/common/mocks/recognizer.go +++ b/tests/common/mocks/recognizer.go @@ -1,18 +1,18 @@ package mocks import ( - "context" + "github.com/stretchr/testify/mock" - rec "watchtower/internal/support/task/application/service/recognizer" + "watchtower/internal/shared/kernel" - "github.com/stretchr/testify/mock" + rec "watchtower/internal/support/task/application/service/recognizer" ) type MockRecognizer struct { mock.Mock } -func (m *MockRecognizer) Recognize(_ context.Context, params *rec.RecognizeParams) (*rec.Recognized, error) { +func (m *MockRecognizer) Recognize(_ kernel.Ctx, params *rec.RecognizeParams) (*rec.Recognized, error) { args := m.Called(params) return args.Get(0).(*rec.Recognized), args.Error(1) } diff --git a/tests/common/mocks/task_queue.go b/tests/common/mocks/task_queue.go new file mode 100644 index 0000000..4f244c9 --- /dev/null +++ b/tests/common/mocks/task_queue.go @@ -0,0 +1,33 @@ +package mocks + +import ( + "github.com/stretchr/testify/mock" + + "watchtower/internal/shared/kernel" + "watchtower/internal/support/task/domain" +) + +type MockTaskQueue struct { + mock.Mock + + Ch chan domain.Message +} + +func (m *MockTaskQueue) Publish(_ kernel.Ctx, msg domain.Message) error { + args := m.Called(msg) + return args.Error(0) +} + +func (m *MockTaskQueue) GetConsumerChannel() chan domain.Message { + return m.Ch +} + +func (m *MockTaskQueue) StartConsuming(_ kernel.Ctx) error { + args := m.Called() + return args.Error(0) +} + +func (m *MockTaskQueue) StopConsuming(_ kernel.Ctx) error { + args := m.Called() + return args.Error(0) +} diff --git a/tests/common/mocks/task_storage.go b/tests/common/mocks/task_storage.go new file mode 100644 index 0000000..6097e88 --- /dev/null +++ b/tests/common/mocks/task_storage.go @@ -0,0 +1,27 @@ +package mocks + +import ( + "github.com/stretchr/testify/mock" + + "watchtower/internal/shared/kernel" + "watchtower/internal/support/task/domain" +) + +type MockTaskStorage struct { + mock.Mock +} + +func (m *MockTaskStorage) GetTask(_ kernel.Ctx, bucketID kernel.BucketID, taskID kernel.TaskID) (*domain.Task, error) { + args := m.Called(bucketID, taskID) + return args.Get(0).(*domain.Task), args.Error(1) +} + +func (m *MockTaskStorage) GetAllBucketTasks(_ kernel.Ctx, bucketID kernel.BucketID) ([]*domain.Task, error) { + args := m.Called(bucketID) + return args.Get(0).([]*domain.Task), args.Error(1) +} + +func (m *MockTaskStorage) UpdateTask(_ kernel.Ctx, task *domain.Task) error { + args := m.Called(task) + return args.Error(0) +} diff --git a/tests/common/usecase.go b/tests/common/usecase.go index 74ec55d..3b8e1ff 100644 --- a/tests/common/usecase.go +++ b/tests/common/usecase.go @@ -7,10 +7,11 @@ import ( "os" "time" - "watchtower/cmd/watchtower/config" + "github.com/breadrock1/otlp-go/otlp" + "watchtower/cmd" + "watchtower/internal/core/cloud/infrastructure/s3" "watchtower/internal/process" - "watchtower/internal/shared/telemetry" "watchtower/internal/support/task/infrastructure/redis" "watchtower/internal/support/task/infrastructure/rmq" "watchtower/tests/common/mocks" @@ -36,13 +37,14 @@ type TestEnvironment struct { func InitTestEnvironment(configFilePath string) (*TestEnvironment, error) { ctx := context.Background() - servConfig, err := config.FromFile(configFilePath) + + servConfig, err := cmd.InitConfig() if err != nil { return nil, fmt.Errorf("failed to read config file %s: %w", configFilePath, err) } - tracerProvider, _ := telemetry.InitTracer(servConfig.Otlp.Tracer) - telemetry.GlobalTracer = tracerProvider + tracerProvider, _ := otlp_go.InitTracer(servConfig.Otlp.Tracer) + otlp_go.GlobalTracer = tracerProvider docParser := new(mocks.MockRecognizer) docStorage := new(mocks.MockDocStorage) diff --git a/tests/routes/bucket_routes_test.go b/tests/routes/bucket_routes_test.go new file mode 100644 index 0000000..d174a77 --- /dev/null +++ b/tests/routes/bucket_routes_test.go @@ -0,0 +1,262 @@ +package routes_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "watchtower/cmd" + "watchtower/cmd/watchtower/httpserver/form" + "watchtower/internal/core/cloud/domain" + "watchtower/tests/common" +) + +const ( + TestBucketName = "test-bucket-name" + TestBucketPath = "/" + + GetAllBucketsURL = "/api/v1/cloud/buckets" + CreateBucketURL = "/api/v1/cloud/bucket" + + GetAllBucketsMethod = "GetAllBuckets" + DeleteBucketMethod = "DeleteBucket" + CreateBucketMethod = "CreateBucket" + IsBucketExistsMethod = "IsBucketExist" +) + +var ( + BucketCreatedAt = time.Now() + TestBucket = domain.Bucket{ + ID: TestBucketName, + Path: TestBucketPath, + CreatedAt: BucketCreatedAt, + } +) + +func TestBucketAPIRoutes(t *testing.T) { + servConfig, err := cmd.InitConfig() + assert.NoError(t, err, "failed to read config file") + + var getBucketsTestCases = []struct { + TargetURL string + HttpMethod string + MockMethodName string + ReturnedData interface{} + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: GetAllBucketsURL, + HttpMethod: http.MethodGet, + MockMethodName: GetAllBucketsMethod, + ReturnedData: []domain.Bucket{TestBucket}, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusOK, + }, + { + TargetURL: GetAllBucketsURL, + HttpMethod: http.MethodGet, + MockMethodName: GetAllBucketsMethod, + ReturnedData: []domain.Bucket{TestBucket}, + ReturnedError: fmt.Errorf("failed get all buckets"), + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + t.Run("Get buckets", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range getBucketsTestCases { + testCaseName := fmt.Sprintf("Get buckets case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.ObjectStorage. + On(testCase.MockMethodName). + Return(testCase.ReturnedData, testCase.ReturnedError) + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, nil) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to create tag") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.ObjectStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) + + var createBucketTestCases = []struct { + TargetURL string + HttpMethod string + IsBucketExists bool + IsBucketExistsError error + MockMethodName string + ReturnedError error + RequestPayload *domain.Bucket + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: CreateBucketURL, + HttpMethod: http.MethodPut, + IsBucketExists: false, + IsBucketExistsError: nil, + MockMethodName: CreateBucketMethod, + ReturnedError: nil, + RequestPayload: &TestBucket, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusCreated, + }, + { + TargetURL: CreateBucketURL, + HttpMethod: http.MethodPut, + IsBucketExists: false, + IsBucketExistsError: nil, + MockMethodName: CreateBucketMethod, + ReturnedError: nil, + RequestPayload: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + TargetURL: CreateBucketURL, + HttpMethod: http.MethodPut, + IsBucketExists: true, + IsBucketExistsError: nil, + MockMethodName: CreateBucketMethod, + ReturnedError: nil, + RequestPayload: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + // TODO: Temporary implementation + //ExpectedStatusCode: http.StatusConflict, + }, + { + TargetURL: CreateBucketURL, + HttpMethod: http.MethodPut, + IsBucketExists: false, + IsBucketExistsError: nil, + MockMethodName: CreateBucketMethod, + ReturnedError: fmt.Errorf("failed create bucket"), + RequestPayload: &TestBucket, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + t.Run("Delete bucket", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range createBucketTestCases { + testCaseName := fmt.Sprintf("Create bucket case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.ObjectStorage. + On(IsBucketExistsMethod, TestBucket.ID). + Return(testCase.IsBucketExists, testCase.IsBucketExistsError) + + testEnv.ObjectStorage. + On(testCase.MockMethodName, TestBucket.ID). + Return(testCase.ReturnedError) + + var buffer = bytes.NewBuffer(nil) + if testCase.RequestPayload != nil { + jsonBytes, err := json.Marshal(form.CreateBucketForm{BucketName: testCase.RequestPayload.ID}) + assert.NoError(t, err, "failed to marshal request body") + buffer = bytes.NewBuffer(jsonBytes) + } + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, buffer) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to delete tag") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.ObjectStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) + + var deleteBucketsTestCases = []struct { + TargetURL string + HttpMethod string + MockMethodName string + IsBucketExists bool + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s", TestBucket.ID), + HttpMethod: http.MethodDelete, + MockMethodName: DeleteBucketMethod, + IsBucketExists: true, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusOK, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s", TestBucket.ID), + HttpMethod: http.MethodDelete, + MockMethodName: DeleteBucketMethod, + IsBucketExists: false, + ReturnedError: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusNotFound, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s", TestBucket.ID), + HttpMethod: http.MethodDelete, + MockMethodName: DeleteBucketMethod, + IsBucketExists: true, + ReturnedError: fmt.Errorf("failed to delete bucket"), + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + t.Run("Delete buckets", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range deleteBucketsTestCases { + testCaseName := fmt.Sprintf("Delete bucket case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.ObjectStorage. + On(IsBucketExistsMethod, TestBucket.ID). + Return(testCase.IsBucketExists, nil) + + testEnv.ObjectStorage. + On(testCase.MockMethodName, TestBucket.ID). + Return(testCase.ReturnedError, testCase.ReturnedError) + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, nil) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "delete bucket") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.ObjectStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) +} diff --git a/tests/routes/object_routes_test.go b/tests/routes/object_routes_test.go new file mode 100644 index 0000000..3ee28d3 --- /dev/null +++ b/tests/routes/object_routes_test.go @@ -0,0 +1,361 @@ +package routes_test + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + "watchtower/cmd/watchtower/httpserver/form" + "watchtower/internal/core/cloud/domain" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "watchtower/cmd" + "watchtower/tests/common" +) + +const ( + TestObjectName = "test-object-name.docx" + TestObjectPath = "./test-object.docx" + TestObjectNewPath = "./test/test-object.docx" + TestObjectContentType = "application/docx" + TestObjectSize = 1024 + TestFolderPath = "test-folder" + + IsBucketExistsMethodName = "IsBucketExist" + CopyObjectMethodName = "CopyObject" + StoreObjectMethodName = "StoreObject" + DeleteObjectMethodName = "DeleteObject" + DeleteObjectsMethodName = "DeleteObjects" +) + +var ( + TestObjectID = "test-object-id" + TestObjectChecksum = md5.New() + TestObjectCreatedAt = time.Now() + + TestObject = domain.Object{ + Name: TestObjectName, + Path: TestObjectPath, + Checksum: fmt.Sprintf("%x", TestObjectChecksum), + ContentType: TestObjectContentType, + Expired: TestObjectCreatedAt, + LastModified: TestObjectCreatedAt, + Size: TestObjectSize, + IsDirectory: false, + } + + TestCopyFileForm = form.CopyFileForm{ + SrcPath: TestObjectPath, + DstPath: TestObjectNewPath, + } + + TestCreateFolderForm = form.FolderForm{ + Prefix: TestFolderPath, + } + + MatchedStoreObjectParams = mock.MatchedBy(func(params *domain.UploadObjectParams) bool { + filePathFlag := params.FilePath == fmt.Sprintf("%s/.keeper", TestFolderPath) + return filePathFlag + }) + + MatchedCopyFilesParams = mock.MatchedBy(func(params *domain.CopyObjectParams) bool { + srcPathFlag := params.SourcePath == TestObjectPath + dstPathFlag := params.DestinationPath == TestObjectNewPath + return srcPathFlag && dstPathFlag + }) +) + +//nolint +func TestObjectAPIRoutes(t *testing.T) { + servConfig, err := cmd.InitConfig() + assert.NoError(t, err, "failed to read config file") + + var copyFileTestCases = []struct { + TargetURL string + HttpMethod string + MockMethodName string + RequestPayload *form.CopyFileForm + IsBucketExists bool + ReturnedData interface{} + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/file", TestBucketName), + HttpMethod: http.MethodPatch, + MockMethodName: CopyObjectMethodName, + RequestPayload: &TestCopyFileForm, + IsBucketExists: true, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusOK, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/file", TestBucketName), + HttpMethod: http.MethodPatch, + RequestPayload: &TestCopyFileForm, + IsBucketExists: false, + MockMethodName: StoreObjectMethodName, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusNotFound, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/file", TestBucketName), + HttpMethod: http.MethodPatch, + MockMethodName: CopyObjectMethodName, + RequestPayload: nil, + IsBucketExists: true, + ReturnedData: nil, + ReturnedError: errors.New("invalid request"), + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/file", TestBucketName), + HttpMethod: http.MethodPatch, + MockMethodName: CopyObjectMethodName, + RequestPayload: &TestCopyFileForm, + IsBucketExists: true, + ReturnedData: nil, + ReturnedError: errors.New("internal error"), + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + t.Run("Copy file", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range copyFileTestCases { + testCaseName := fmt.Sprintf("Copy file case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.ObjectStorage. + On(IsBucketExistsMethodName, TestBucketName). + Return(testCase.IsBucketExists, nil) + + testEnv.ObjectStorage. + On(testCase.MockMethodName, TestBucketName, MatchedCopyFilesParams). + Return(testCase.ReturnedError) + + var buffer = bytes.NewBuffer(nil) + if testCase.RequestPayload != nil { + jsonBytes, err := json.Marshal(testCase.RequestPayload) + assert.NoError(t, err, "failed to marshal request body") + buffer = bytes.NewBuffer(jsonBytes) + } + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, buffer) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to copy file") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.ObjectStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) + + var createFolderTestCases = []struct { + TargetURL string + HttpMethod string + RequestPayload *form.FolderForm + IsBucketExists bool + MockMethodName string + ReturnedData interface{} + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodPost, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: true, + MockMethodName: StoreObjectMethodName, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusCreated, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodPost, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: false, + MockMethodName: StoreObjectMethodName, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusNotFound, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodPost, + RequestPayload: nil, + IsBucketExists: true, + MockMethodName: StoreObjectMethodName, + ReturnedData: nil, + ReturnedError: errors.New("invalid folder name"), + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodPost, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: true, + MockMethodName: StoreObjectMethodName, + ReturnedData: nil, + ReturnedError: errors.New("internal server error"), + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + //nolint + t.Run("Create folder", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range createFolderTestCases { + testCaseName := fmt.Sprintf("Create folder case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.ObjectStorage. + On(IsBucketExistsMethodName, TestBucketName). + Return(testCase.IsBucketExists, nil) + + testEnv.ObjectStorage. + On(testCase.MockMethodName, TestBucketName, MatchedStoreObjectParams). + Return(TestObjectID, testCase.ReturnedError) + + var buffer = bytes.NewBuffer(nil) + if testCase.RequestPayload != nil { + jsonBytes, err := json.Marshal(testCase.RequestPayload) + assert.NoError(t, err, "failed to marshal request body") + buffer = bytes.NewBuffer(jsonBytes) + } + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, buffer) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to create folder") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.ObjectStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) + + var deleteFolderTestCases = []struct { + TargetURL string + HttpMethod string + RequestPayload *form.FolderForm + IsBucketExists bool + MockMethodName string + ReturnedData interface{} + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodDelete, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: true, + MockMethodName: DeleteObjectsMethodName, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusOK, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodDelete, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: false, + MockMethodName: DeleteObjectsMethodName, + ReturnedData: nil, + ReturnedError: errors.New("bucket not found"), + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusNotFound, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodDelete, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: true, + MockMethodName: DeleteObjectMethodName, + ReturnedData: nil, + ReturnedError: errors.New("folder not empty"), + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusInternalServerError, + }, + { + TargetURL: fmt.Sprintf("/api/v1/cloud/%s/folder", TestBucketName), + HttpMethod: http.MethodDelete, + RequestPayload: &TestCreateFolderForm, + IsBucketExists: true, + MockMethodName: DeleteObjectMethodName, + ReturnedData: nil, + ReturnedError: errors.New("service unavailable"), + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + //nolint + t.Run("Delete folder", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range deleteFolderTestCases { + testCaseName := fmt.Sprintf("Delete folder case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.ObjectStorage. + On(IsBucketExistsMethodName, TestBucketName). + Return(testCase.IsBucketExists, nil) + + testEnv.ObjectStorage. + On(testCase.MockMethodName, TestBucketName, TestFolderPath). + Return(TestObjectID, testCase.ReturnedError) + + var buffer = bytes.NewBuffer(nil) + if testCase.RequestPayload != nil { + jsonBytes, err := json.Marshal(testCase.RequestPayload) + assert.NoError(t, err, "failed to marshal request body") + buffer = bytes.NewBuffer(jsonBytes) + } + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, buffer) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to delete folder") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.ObjectStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) +} diff --git a/tests/routes/task_routes_test.go b/tests/routes/task_routes_test.go new file mode 100644 index 0000000..956444a --- /dev/null +++ b/tests/routes/task_routes_test.go @@ -0,0 +1,195 @@ +package routes_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "watchtower/cmd" + "watchtower/internal/shared/kernel" + "watchtower/internal/support/task/domain" + "watchtower/tests/common" +) + +const ( + TestTaskStatus = "done" + TestObjectDataSize = 1024 + + IncorrectTaskID = "incorrect-task-id" + + GetTaskMethod = "GetTask" + LoadTasksMethod = "GetAllBucketTasks" +) + +var ( + TestTaskID = uuid.New() + TestTaskCreated = time.Now() + TestTask = domain.Task{ + ID: TestTaskID, + BucketID: TestBucket.ID, + ObjectID: TestObjectID, + ObjectDataSize: TestObjectDataSize, + StatusText: TestTaskStatus, + Status: domain.Successful, + CreatedAt: TestTaskCreated, + ModifiedAt: TestTaskCreated, + RetryCount: 0, + MaxRetries: 0, + ProcessingDuration: 1 * time.Second, + } + + matchedBucketID = mock.MatchedBy(func(id kernel.BucketID) bool { + return id == TestBucket.ID + }) + + matchedTaskID = mock.MatchedBy(func(id kernel.TaskID) bool { + return id.String() == TestTaskID.String() + }) +) + +func TestTaskAPIRoutes(t *testing.T) { + servConfig, err := cmd.InitConfig() + assert.NoError(t, err, "failed to read config file") + + var loadTasksTestCases = []struct { + TargetURL string + HttpMethod string + MockMethodName string + ReturnedData interface{} + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s?status=0", TestBucketName), + HttpMethod: http.MethodGet, + MockMethodName: LoadTasksMethod, + ReturnedData: []*domain.Task{&TestTask}, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusOK, + }, + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s", TestBucketName), + HttpMethod: http.MethodGet, + MockMethodName: LoadTasksMethod, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s?status=kek", TestBucketName), + HttpMethod: http.MethodGet, + MockMethodName: LoadTasksMethod, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s?status=0", TestBucketName), + HttpMethod: http.MethodGet, + MockMethodName: LoadTasksMethod, + ReturnedData: []*domain.Task{&TestTask}, + ReturnedError: fmt.Errorf("failed to load tasks"), + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + t.Run("Load tasks", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range loadTasksTestCases { + testCaseName := fmt.Sprintf("Load tasks case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.TaskStorage. + On(testCase.MockMethodName, matchedBucketID). + Return(testCase.ReturnedData, testCase.ReturnedError) + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, nil) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to load tasks") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.TaskStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) + + var loadTaskByIDTestCases = []struct { + TargetURL string + HttpMethod string + MockMethodName string + ReturnedData interface{} + ReturnedError error + ExpectedCalledTimes int + ExpectedStatusCode int + }{ + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s/%s", TestBucketName, TestTaskID.String()), + HttpMethod: http.MethodGet, + MockMethodName: GetTaskMethod, + ReturnedData: &TestTask, + ReturnedError: nil, + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusOK, + }, + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s/%s", TestBucketName, IncorrectTaskID), + HttpMethod: http.MethodGet, + MockMethodName: GetTaskMethod, + ReturnedData: nil, + ReturnedError: nil, + ExpectedCalledTimes: 0, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + TargetURL: fmt.Sprintf("/api/v1/tasks/%s/%s", TestBucketName, TestTaskID.String()), + HttpMethod: http.MethodGet, + MockMethodName: GetTaskMethod, + ReturnedData: &TestTask, + ReturnedError: fmt.Errorf("failed load task by id"), + ExpectedCalledTimes: 1, + ExpectedStatusCode: http.StatusInternalServerError, + }, + } + + t.Run("Load task by id", func(t *testing.T) { + ctx := context.Background() + + for index, testCase := range loadTaskByIDTestCases { + testCaseName := fmt.Sprintf("Load task by id case %d", index) + t.Run(testCaseName, func(t *testing.T) { + testEnv := common.InitTestAppEnvironment() + appServer, err := testEnv.BuildAppServer(servConfig) + assert.NoError(t, err, "failed to build app server") + + testEnv.TaskStorage. + On(testCase.MockMethodName, matchedBucketID, matchedTaskID). + Return(testCase.ReturnedData, testCase.ReturnedError) + + req := httptest.NewRequestWithContext(ctx, testCase.HttpMethod, testCase.TargetURL, nil) + + resp, respErr := appServer.Server.Test(req, -1) + assert.NoError(t, respErr, "failed to load task by id") + assert.Equal(t, testCase.ExpectedStatusCode, resp.StatusCode, "unexpected http status code") + + testEnv.TaskStorage.AssertNumberOfCalls(t, testCase.MockMethodName, testCase.ExpectedCalledTimes) + }) + } + }) +} diff --git a/tests/storage_test.go b/tests/storage_test.go index ab9d1ca..aeb4fe4 100644 --- a/tests/storage_test.go +++ b/tests/storage_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "watchtower/internal/core/cloud/domain" + "watchtower/internal/shared/kernel" "watchtower/tests/common" ) @@ -63,7 +64,7 @@ func TestStorage(t *testing.T) { }) } -func StoreObjectToStorage(ctx context.Context, testEnv *common.TestEnvironment, filePath string) error { +func StoreObjectToStorage(ctx kernel.Ctx, testEnv *common.TestEnvironment, filePath string) error { uploadParams := domain.UploadObjectParams{ FilePath: filePath, FileData: bytes.NewBufferString("there is some file content"),