diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 10653eb..c47d344 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,6 +18,10 @@ on: - "go.mod" - "go.sum" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: golangci: name: Lint @@ -35,7 +39,7 @@ jobs: # step 3: run golangci-lint - name: Run golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v8 with: version: latest args: --timeout=5m @@ -60,10 +64,67 @@ jobs: # step 4: run test - name: Run coverage - run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + run: go test -race -shuffle=on -count=1 -covermode=atomic -coverpkg=./... -coverprofile=coverage.out ./... # step 5: upload coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + + benchmark: + name: Benchmark + runs-on: ubuntu-latest + if: false + permissions: + contents: read + pull-requests: write + steps: + # step 1: checkout repository code + - name: Checkout code into workspace directory + uses: actions/checkout@v4 + + # step 2: set up go + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + # step 3: install dependencies + - name: Install all Go dependencies + run: go mod download + + # step 4: restore previous benchmark history (if exists) + - name: Restore benchmark history + id: benchmark-cache + uses: actions/cache/restore@v4 + with: + path: ./cache + key: ${{ runner.os }}-benchmark-${{ github.ref_name }} + restore-keys: | + ${{ runner.os }}-benchmark- + + # step 5: run benchmark + - name: Run benchmarks + run: go test -run=^$ -bench=. -benchmem ./... | tee benchmark.txt + + # step 6: upload benchmark + - name: Upload benchmark results + uses: benchmark-action/github-action-benchmark@v1 + with: + # What benchmark tool the benchmark.txt came from + tool: "go" + # Where the output from the benchmark tool is stored + output-file-path: benchmark.txt + # Where the previous data file is stored + external-data-json-path: ./cache/benchmark-data.json + # Workflow will fail when an alert happens + fail-on-alert: true + + # step 7: persist updated benchmark history + - name: Save benchmark history + if: always() + uses: actions/cache/save@v4 + with: + path: ./cache + key: ${{ runner.os }}-benchmark-${{ github.ref_name }}-${{ github.run_id }} diff --git a/.gitignore b/.gitignore index 705a3bd..30f01cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig -# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,linux,go,dotenv -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,macos,linux,go,dotenv +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,dotenv,go,linux,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,dotenv,go,linux,windows ### dotenv ### .env @@ -50,7 +50,8 @@ go.work .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -120,7 +121,16 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,linux,go,dotenv +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,dotenv,go,linux,windows # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) +dist/ +tmp/ + +/configs/* +!/configs/*.example.* + +go.work* +coverage.* +benchmark.* diff --git a/.golangci.yml b/.golangci.yml index 925e418..e1ad04c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,8 +1,7 @@ -# Original file link: https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 # This file is licensed under the terms of the MIT license https://opensource.org/license/mit # Copyright (c) 2021-2025 Marat Reymers -## Golden config for golangci-lint v1.64.7 +## Golden config for golangci-lint v2.5.0 # # This is the best config for golangci-lint based on my experience and opinion. # It is very strict, but not extremely strict. @@ -10,30 +9,35 @@ # If this config helps you, please consider keeping a link to this file (see the next comment). # Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 + version: "2" -run: - # Timeout for analysis, e.g. 30s, 5m. - # Default: 1m - timeout: 3m +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + +formatters: + enable: + - goimports # checks if the code and import statements are formatted according to the 'goimports' command + - golines # checks if code is formatted, and fixes long lines + - swaggo # formats swaggo comments - # The mode used to evaluate relative paths. - # It's used by exclusions, Go plugins, and some linters. - # The value can be: - # - `gomod`: the paths will be relative to the directory of the `go.mod` file. - # - `gitroot`: the paths will be relative to the git root (the parent directory of `.git`). - # - `cfg`: the paths will be relative to the configuration file. - # - `wd` (NOT recommended): the paths will be relative to the place where golangci-lint is run. - # Default: wd - relative-path-mode: gomod + ## you may want to enable + #- gci # checks if code and import statements are formatted, with additional rules + #- gofmt # checks if the code is formatted according to 'gofmt' command + #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible -# This file contains only configs which differ from defaults. -# All possible options can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + settings: + golines: + # Target maximum line length. + # Default: 100 + max-len: 120 linters: - default: none enable: - # enabled by default - asasalint # checks for pass []any as any in variadic func(...any) - asciicheck # checks that your code does not contain non-ASCII identifiers - bidichk # checks for dangerous unicode character sequences @@ -44,6 +48,7 @@ linters: - depguard # checks if package imports are in a list of acceptable packages - dupl # tool for code clone detection - durationcheck # checks for two durations multiplied together + - embeddedstructfieldcheck # checks embedded types in structs - err113 # [too strict] checks the errors handling expressions - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error @@ -53,6 +58,7 @@ linters: - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions - fatcontext # detects nested contexts in loops - forbidigo # forbids identifiers + - funcorder # checks the order of functions, methods, and constructors - funlen # tool for detection of long functions - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - gochecknoglobals # checks that no global variables exist @@ -62,16 +68,16 @@ linters: - goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues - gocyclo # computes and checks the cyclomatic complexity of functions - #- godot # checks if comments end in a period + - godoclint # checks Golang's documentation practice + - godot # checks if comments end in a period - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - goprintffuncname # checks that printf-like functions are named with f at the end - gosec # inspects source code for security problems - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - # - gosimple # specializes in simplifying a code TODO: it's not working for version 2.0.0 - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution - ineffassign # detects when assignments to existing variables are not used - intrange # finds places where for loops could make use of an integer range - - lll # reports long lines + - iotamixing # checks if iotas are being used in const blocks with other non-iota declarations - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - makezero # finds slice declarations with non-zero initial length - mirror # reports wrong mirror patterns of bytes/strings usage @@ -102,9 +108,9 @@ linters: - testifylint # checks usage of github.com/stretchr/testify - testpackage # makes you use a separate _test package - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes - #- typecheck # like the front-end of a Go compiler, parses and type-checks Go code TODO: it's not working for version 2.0.0 - unconvert # removes unnecessary type conversions - unparam # reports unused function parameters + - unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection - unused # checks for unused constants, variables, functions and types - usestdlibvars # detects the possibility to use variables/constants from the Go standard library - usetesting # reports uses of functions with replacement inside the testing package @@ -112,13 +118,50 @@ linters: - whitespace # detects leading and trailing whitespace - wrapcheck # checks that errors returned from external packages are wrapped + ## you may want to enable + #- arangolint # opinionated best practices for arangodb client + #- decorder # checks declaration order and count of types, constants, variables and functions + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects usage of FIXME, TODO and other keywords inside comments + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- lll # [replaced by golines] reports long lines + #- maintidx # measures the maintainability index of each function + #- misspell # [useless] finds commonly misspelled English words in comments + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines + + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: cyclop: # The maximal code complexity to report. # Default: 10 max-complexity: 30 # The maximal average package complexity. - # If it's higher than 0.0 (float) the check is enabled + # If it's higher than 0.0 (float) the check is enabled. # Default: 0.0 package-average: 10.0 @@ -139,8 +182,11 @@ linters: # # Default (applies if no custom rules are defined): Only allow $gostd in all files. rules: - deprecated: + "deprecated": # List of file globs that will match this list of settings to compare against. + # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. + # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. + # The placeholder '${config-path}' is substituted with a path relative to the configuration file. # Default: $all files: - "$all" @@ -148,24 +194,29 @@ linters: # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). # Default: [] deny: - - pkg: "github.com/golang/protobuf" - desc: "Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" - - pkg: "github.com/satori/go.uuid" - desc: "Use github.com/google/uuid instead, satori's package is not maintained" - - pkg: "github.com/gofrs/uuid$" - desc: "Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5" - non-main files: + - pkg: github.com/golang/protobuf + desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - pkg: github.com/satori/go.uuid + desc: Use github.com/google/uuid instead, satori's package is not maintained + - pkg: github.com/gofrs/uuid$ + desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 + "non-test files": files: - - "!**/main.go" + - "!$test" deny: - - pkg: "log$" - desc: "Use log/slog instead, see https://go.dev/blog/slog" - non-test files: + - pkg: math/rand$ + desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 + "non-main files": files: - - "!$test" + - "!**/main.go" deny: - - pkg: "math/rand$" - desc: "Use math/rand/v2 instead, see https://go.dev/blog/randv2" + - pkg: log$ + desc: Use log/slog instead, see https://go.dev/blog/slog + + embeddedstructfieldcheck: + # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. + # Default: false + forbid-mutex: true errcheck: # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. @@ -181,33 +232,60 @@ linters: - map exhaustruct: - # List of regular expressions to exclude struct packages and their names from checks. - # Regular expressions must match complete canonical struct package/name/structname. + # List of regular expressions to match type names that should be excluded from processing. + # Anonymous structs can be matched by '' alias. + # Has precedence over `include`. + # Each regular expression must match the full type name, including package path. + # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, + # but not `http\.Cookie`. # Default: [] exclude: # std libs - - "^net/http.Client$" - - "^net/http.Cookie$" - - "^net/http.Request$" - - "^net/http.Response$" - - "^net/http.Server$" - - "^net/http.Transport$" - - "^net/url.URL$" - - "^os/exec.Cmd$" - - "^reflect.StructField$" + - ^net.ListenConfig$ + - ^net/http.Client$ + - ^net/http.Cookie$ + - ^net/http.Request$ + - ^net/http.Response$ + - ^net/http.Server$ + - ^net/http.Transport$ + - ^net/url.URL$ + - ^os/exec.Cmd$ + - ^reflect.StructField$ # public libs - - "^github.com/Shopify/sarama.Config$" - - "^github.com/Shopify/sarama.ProducerMessage$" - - "^github.com/mitchellh/mapstructure.DecoderConfig$" - - "^github.com/prometheus/client_golang/.+Opts$" - - "^github.com/spf13/cobra.Command$" - - "^github.com/spf13/cobra.CompletionOptions$" - - "^github.com/stretchr/testify/mock.Mock$" - - "^github.com/testcontainers/testcontainers-go.+Request$" - - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" - - "^golang.org/x/tools/go/analysis.Analyzer$" - - "^google.golang.org/protobuf/.+Options$" - - "^gopkg.in/yaml.v3.Node$" + - "^github.com/gofiber/.+Config$" + - "^gopkg.in/telebot.v4.LongPoller$" + - "^gopkg.in/telebot.v4.ReplyMarkup$" + - "^gopkg.in/telebot.v4.Settings$" + - ^firebase.google.com/go/v4/messaging.AndroidConfig$ + - ^firebase.google.com/go/v4/messaging.Message$ + - ^github.com/aws/aws-sdk-go-v2/service/s3.+Input$ + - ^github.com/aws/aws-sdk-go-v2/service/s3/types.ObjectIdentifier$ + - ^github.com/go-telegram-bot-api/telegram-bot-api/.+Config$ + - ^github.com/mitchellh/mapstructure.DecoderConfig$ + - ^github.com/prometheus/client_golang/.+Opts$ + - ^github.com/secsy/goftp.Config$ + - ^github.com/Shopify/sarama.Config$ + - ^github.com/Shopify/sarama.ProducerMessage$ + - ^github.com/spf13/cobra.Command$ + - ^github.com/spf13/cobra.CompletionOptions$ + - ^github.com/stretchr/testify/mock.Mock$ + - ^github.com/testcontainers/testcontainers-go.+Request$ + - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ + - ^github.com/urfave/cli.v3.ArgumentBase$ + - ^github.com/urfave/cli.v3.Command$ + - ^github.com/urfave/cli.v3.FlagBase$ + - ^golang.org/x/tools/go/analysis.Analyzer$ + - ^google.golang.org/protobuf/.+Options$ + - ^gopkg.in/yaml.v3.Node$ + - ^gorm.io/gorm/clause.+$ + # Allows empty structures in return statements. + # Default: false + allow-empty-returns: true + + funcorder: + # Checks if the exported methods of a structure are placed before the non-exported ones. + # Default: true + struct-method: false funlen: # Checks the number of lines in a function. @@ -218,24 +296,21 @@ linters: # If lower than 0, disable the check. # Default: 40 statements: 50 - # Ignore comments when counting lines. - # Default false - ignore-comments: true - - gocognit: - # Minimal code complexity to report. - # Default: 30 (but we recommend 10-20) - min-complexity: 20 gochecksumtype: # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. # Default: true default-signifies-exhaustive: false + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + gocritic: # Settings passed to gocritic. # The settings key is the name of a supported gocritic checker. - # The list of supported checkers can be find in https://go-critic.github.io/overview. + # The list of supported checkers can be found at https://go-critic.com/overview. settings: captLocal: # Whether to restrict checker to params only. @@ -246,12 +321,20 @@ linters: # Default: true skipRecvDeref: false + godoclint: + # List of rules to enable in addition to the default set. + # Default: empty + enable: + # Assert no unused link in godocs. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link + - no-unused-link + govet: # Enable all analyzers. # Default: false enable-all: true # Disable analyzers by name. - # Run `go tool vet help` to see all analyzers. + # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. # Default: [] disable: - fieldalignment # too strict @@ -295,10 +378,7 @@ linters: nolintlint: # Exclude following linters from requiring an explanation. # Default: [] - allow-no-explanation: - - funlen - - gocognit - - lll + allow-no-explanation: [funlen, gocognit, golines] # Enable to require an explanation of nonzero length after each nolint directive. # Default: false require-explanation: true @@ -319,7 +399,7 @@ linters: - ".*" rowserrcheck: - # database/sql is always checked + # database/sql is always checked. # Default: [] packages: - github.com/jmoiron/sqlx @@ -332,7 +412,7 @@ linters: # - "default": report only the default slog logger # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global # Default: "" - no-global: "all" + no-global: all # Enforce using methods that accept a context. # Values: # - "": disabled @@ -340,59 +420,65 @@ linters: # - "scope": report only if a context exists in the scope of the outermost function # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only # Default: "" - context: "scope" + context: scope + + staticcheck: + # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks + # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] + # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Use consistent method receiver names. + # https://staticcheck.dev/docs/checks/#ST1016 + - -ST1016 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 usetesting: # Enable/disable `os.TempDir()` detections. # Default: false os-temp-dir: true + wrapcheck: + extra-ignore-sigs: + - .JSON( + - .SendStatus( + exclusions: generated: lax + # Predefined exclusion rules. + # Default: [] presets: - - comments - - common-false-positives - - legacy - std-error-handling + - common-false-positives + # Excluding configuration per-path, per-linter, per-text and per-source. rules: - - linters: - - govet - text: 'shadow: declaration of "(err|ctx)" shadows declaration at' - - linters: - - godot - source: (noinspection|TODO) - - linters: - - gocritic - source: //noinspection - - linters: + - source: "TODO" + linters: [godot] + - text: "should have a package comment" + linters: [revive] + - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' + linters: [revive] + - text: 'package comment should be of the form ".+"' + source: "// ?(nolint|TODO)" + linters: [revive] + - text: 'comment on exported \S+ \S+ should be of the form ".+"' + source: "// ?(nolint|TODO)" + linters: [revive, staticcheck] + - path: '_test\.go' + linters: - bodyclose - dupl + - err113 - errcheck - exhaustruct - funlen + - gocognit - goconst - gosec - - lll - noctx - wrapcheck - path: _test\.go - paths: - - third_party$ - - builtin$ - - examples$ - -issues: - # Maximum count of issues with the same text. - # Set to 0 to disable. - # Default: 3 - max-same-issues: 50 - -formatters: - enable: - - goimports - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ diff --git a/Makefile b/Makefile index d82e89b..69ba0db 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,65 @@ -# Go parameters -GOCMD=go -GOBUILD=$(GOCMD) build -GOCLEAN=$(GOCMD) clean -GOTEST=$(GOCMD) test -GOGET=$(GOCMD) get -BINARY_PATH=build -BINARY_NAME=$(BINARY_PATH)/smsgateway -BINARY_UNIX=$(BINARY_NAME)_unix - -all: test build -build: - $(GOBUILD) -o $(BINARY_NAME) -v -test: - $(GOTEST) -race -coverprofile=coverage.out -covermode=atomic ./... -clean: - $(GOCLEAN) - rm -rf $(BINARY_PATH) -run: - $(GOBUILD) -o $(BINARY_NAME) -v . - ./$(BINARY_NAME) -deps: - $(GOGET) -v ./... - -# Cross compilation -build-linux: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v - -# Linting -lint: - golangci-lint run -v +.PHONY: all version fmt lint test coverage benchmark air deps release clean docker-build docker-up docker-down docker-logs help + +BINARY_NAME := $(shell basename $(PWD)) +GIT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0") +VERSION ?= $(GIT_VERSION) +DOCKER_CR ?= $(shell basename $$(dirname $(PWD))) +DOCKER_IMAGE := ${DOCKER_CR}/$(BINARY_NAME):$(VERSION) + +all: fmt lint coverage ## Run all tests and checks + +version: ## Display current version + @echo "Current version: $(VERSION)" + +fmt: ## Format the code + golangci-lint fmt + +lint: ## Lint the code + golangci-lint run --timeout=5m + +test: ## Run tests + go test -race -shuffle=on -count=1 -covermode=atomic -coverpkg=./... -coverprofile=coverage.out ./... + +coverage: test ## Generate coverage + go tool cover -func=coverage.out + go tool cover -html=coverage.out -o coverage.html + +benchmark: ## Run benchmarks + go test -run=^$$ -bench=. -benchmem ./... | tee benchmark.txt + +air: ## Run development server + @command -v air >/dev/null 2>&1 || { \ + echo "Please install air: go install github.com/cosmtrek/air@latest"; \ + exit 1; \ + } + @echo "Starting development server with air..." + @air + +deps: ## Install dependencies + go mod download + +release: ## Create release + goreleaser release --snapshot --clean + +clean: ## Remove build artifacts + rm -f coverage.* benchmark.txt + rm -rf dist + +docker-build: ## Build Docker image + @echo "Building Docker image..." + @docker build -t $(DOCKER_IMAGE) . + +docker-up: ## Start Docker services + @echo "Starting Docker services..." + @docker compose up --build -d + +docker-down: ## Stop Docker services + @echo "Stopping Docker services..." + @docker compose down -v + +docker-logs: ## Show Docker logs + @echo "Showing Docker logs..." + @docker compose logs -f + +help: ## Show help + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) diff --git a/ca/client.go b/ca/client.go index b90fa51..5903524 100644 --- a/ca/client.go +++ b/ca/client.go @@ -13,6 +13,21 @@ type Client struct { *rest.Client } +// NewClient creates a new instance of the CA API Client. +func NewClient(options ...Option) *Client { + config := new(Config) + for _, option := range options { + option(config) + } + + return &Client{ + Client: rest.NewClient(rest.Config{ + Client: config.Client(), + BaseURL: config.BaseURL(), + }), + } +} + // PostCSR posts a Certificate Signing Request (CSR) to the Certificate Authority (CA) service. // // The service will validate the CSR and respond with a request ID. @@ -40,18 +55,3 @@ func (c *Client) GetCSRStatus(ctx context.Context, requestID string) (GetCSRStat return *resp, nil } - -// NewClient creates a new instance of the CA API Client. -func NewClient(options ...Option) *Client { - config := new(Config) - for _, option := range options { - option(config) - } - - return &Client{ - Client: rest.NewClient(rest.Config{ - Client: config.Client(), - BaseURL: config.BaseURL(), - }), - } -} diff --git a/ca/client_test.go b/ca/client_test.go index 634091a..4254903 100644 --- a/ca/client_test.go +++ b/ca/client_test.go @@ -35,7 +35,11 @@ func TestClient_PostCSR(t *testing.T) { } w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte(`{"request_id":"123", "status":"pending", "message": "CSR submitted successfully. Await processing."}`)) + _, _ = w.Write( + []byte( + `{"request_id":"123", "status":"pending", "message": "CSR submitted successfully. Await processing."}`, + ), + ) })) defer server.Close() @@ -96,7 +100,11 @@ func TestClient_GetCSRStatus(t *testing.T) { defer r.Body.Close() w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"request_id":"123", "status":"approved", "message": "CSR approved. The certificate is ready for download.", "certificate":"-----BEGIN CERTIFICATE-----"}`)) + _, _ = w.Write( + []byte( + `{"request_id":"123", "status":"approved", "message": "CSR approved. The certificate is ready for download.", "certificate":"-----BEGIN CERTIFICATE-----"}`, + ), + ) })) defer server.Close() diff --git a/ca/config.go b/ca/config.go index b1b88d1..ae5c9bd 100644 --- a/ca/config.go +++ b/ca/config.go @@ -18,7 +18,7 @@ func (c Config) Client() *http.Client { func (c Config) BaseURL() string { if c.baseURL == "" { - return BASE_URL + return BaseURL } return c.baseURL } diff --git a/ca/config_test.go b/ca/config_test.go index ee62aff..5482395 100644 --- a/ca/config_test.go +++ b/ca/config_test.go @@ -54,7 +54,7 @@ func TestConfig_BaseURL(t *testing.T) { { name: "Without Base URL", option: ca.WithBaseURL(""), - want: ca.BASE_URL, + want: ca.BaseURL, }, } for _, tt := range tests { diff --git a/ca/const.go b/ca/const.go index bf54b57..5162cf2 100644 --- a/ca/const.go +++ b/ca/const.go @@ -1,7 +1,9 @@ package ca -//nolint:revive // backward compatibility -const BASE_URL = "https://ca.sms-gate.app/api/v1" +const BaseURL = "https://ca.sms-gate.app/api/v1" + +//nolint:revive,staticcheck // backward compatibility +const BASE_URL = BaseURL var ( //nolint:gochecknoglobals // constant diff --git a/ca/requests.go b/ca/requests.go index 70b262f..c19276a 100644 --- a/ca/requests.go +++ b/ca/requests.go @@ -4,12 +4,9 @@ import "fmt" // PostCSRRequest represents a request to post a Certificate Signing Request (CSR). type PostCSRRequest struct { - // Type is the type of the CSR. By default, it is set to "webhook". - Type CSRType `json:"type,omitempty" default:"webhook"` - // Content contains the CSR content and is required. - Content string `json:"content" validate:"required,max=16384,startswith=-----BEGIN CERTIFICATE REQUEST-----"` - // Metadata includes additional metadata related to the CSR. - Metadata map[string]string `json:"metadata,omitempty" validate:"dive,keys,max=64,endkeys,max=256"` + Type CSRType `json:"type,omitempty" default:"webhook"` // Type is the type of the CSR. By default, it is set to "webhook". + Content string `json:"content" validate:"required,max=16384,startswith=-----BEGIN CERTIFICATE REQUEST-----"` // Content contains the CSR content and is required. + Metadata map[string]string `json:"metadata,omitempty" validate:"dive,keys,max=64,endkeys,max=256"` // Metadata includes additional metadata related to the CSR. } // Validate checks if the request is valid. diff --git a/rest/client.go b/rest/client.go index bb642d1..290c98e 100644 --- a/rest/client.go +++ b/rest/client.go @@ -18,6 +18,14 @@ type Client struct { config Config } +func NewClient(config Config) *Client { + if config.Client == nil { + config.Client = http.DefaultClient + } + + return &Client{config: config} +} + func (c *Client) Do(ctx context.Context, method, path string, headers map[string]string, payload, response any) error { var reqBody io.Reader if payload != nil { @@ -46,7 +54,7 @@ func (c *Client) Do(ctx context.Context, method, path string, headers map[string } defer func() { _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() + _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { @@ -59,8 +67,8 @@ func (c *Client) Do(ctx context.Context, method, path string, headers map[string return nil } - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return fmt.Errorf("failed to decode response: %w", err) + if decErr := json.NewDecoder(resp.Body).Decode(&response); decErr != nil { + return fmt.Errorf("failed to decode response: %w", decErr) } return nil @@ -81,11 +89,3 @@ func (c *Client) formatError(statusCode int, body []byte) error { // All other client errors (400-499) return fmt.Errorf("%w: unexpected status code %d with body %s", ErrClient, statusCode, string(body)) } - -func NewClient(config Config) *Client { - if config.Client == nil { - config.Client = http.DefaultClient - } - - return &Client{config: config} -} diff --git a/smsgateway/client.go b/smsgateway/client.go index 72fc9d1..db1ed9a 100644 --- a/smsgateway/client.go +++ b/smsgateway/client.go @@ -11,16 +11,13 @@ import ( "github.com/android-sms-gateway/client-go/rest" ) -//nolint:revive // backward compatibility -const BASE_URL = "https://api.sms-gate.app/3rdparty/v1" +const BaseURL = "https://api.sms-gate.app/3rdparty/v1" const settingsPath = "/settings" -type Config struct { - Client *http.Client // Optional HTTP Client, defaults to `http.DefaultClient` - BaseURL string // Optional base URL, defaults to `https://api.sms-gate.app/3rdparty/v1` - User string // Required username - Password string // Required password -} +// Deprecated: BASE_URL is kept for backward compatibility. Use BaseURL instead. +// +//nolint:revive,staticcheck // backward compatibility +const BASE_URL = BaseURL type Client struct { *rest.Client @@ -28,7 +25,29 @@ type Client struct { headers map[string]string } -// Send enqueues a message for sending +// NewClient creates a new instance of the API Client. +func NewClient(config Config) *Client { + if config.BaseURL == "" { + config.BaseURL = BaseURL + } + + headers := make(map[string]string, 1) + if config.Token != "" { + headers["Authorization"] = "Bearer " + config.Token + } else { + headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(config.User+":"+config.Password)) + } + + return &Client{ + Client: rest.NewClient(rest.Config{ + Client: config.Client, + BaseURL: config.BaseURL, + }), + headers: headers, + } +} + +// Send enqueues a message for sending. func (c *Client) Send(ctx context.Context, message Message, options ...SendOption) (MessageState, error) { opts := new(SendOptions).Apply(options...) path := "/messages?" + opts.ToURLValues().Encode() @@ -41,7 +60,7 @@ func (c *Client) Send(ctx context.Context, message Message, options ...SendOptio return *resp, nil } -// GetState returns message state by ID +// GetState returns message state by ID. func (c *Client) GetState(ctx context.Context, messageID string) (MessageState, error) { path := fmt.Sprintf("/messages/%s", url.PathEscape(messageID)) resp := new(MessageState) @@ -53,7 +72,7 @@ func (c *Client) GetState(ctx context.Context, messageID string) (MessageState, return *resp, nil } -// ListDevices returns registered devices +// ListDevices returns registered devices. func (c *Client) ListDevices(ctx context.Context) ([]Device, error) { path := "/devices" var devices []Device @@ -65,7 +84,7 @@ func (c *Client) ListDevices(ctx context.Context) ([]Device, error) { return devices, nil } -// DeleteDevice removes a device by ID +// DeleteDevice removes a device by ID. func (c *Client) DeleteDevice(ctx context.Context, id string) error { path := fmt.Sprintf("/devices/%s", url.PathEscape(id)) @@ -76,7 +95,7 @@ func (c *Client) DeleteDevice(ctx context.Context, id string) error { return nil } -// CheckHealth returns service health status +// CheckHealth returns service health status. func (c *Client) CheckHealth(ctx context.Context) (HealthResponse, error) { path := "/health" resp := new(HealthResponse) @@ -88,7 +107,7 @@ func (c *Client) CheckHealth(ctx context.Context) (HealthResponse, error) { return *resp, nil } -// ExportInbox exports messages via webhooks +// ExportInbox exports messages via webhooks. func (c *Client) ExportInbox(ctx context.Context, req MessagesExportRequest) error { path := "/inbox/export" @@ -99,7 +118,7 @@ func (c *Client) ExportInbox(ctx context.Context, req MessagesExportRequest) err return nil } -// GetLogs retrieves log entries +// GetLogs retrieves log entries. func (c *Client) GetLogs(ctx context.Context, from, to time.Time) ([]LogEntry, error) { query := url.Values{} query.Set("from", from.Format(time.RFC3339)) @@ -114,7 +133,7 @@ func (c *Client) GetLogs(ctx context.Context, from, to time.Time) ([]LogEntry, e return logs, nil } -// GetSettings returns current settings +// GetSettings returns current settings. func (c *Client) GetSettings(ctx context.Context) (DeviceSettings, error) { path := settingsPath resp := new(DeviceSettings) @@ -126,7 +145,7 @@ func (c *Client) GetSettings(ctx context.Context) (DeviceSettings, error) { return *resp, nil } -// UpdateSettings partially updates settings +// UpdateSettings partially updates settings. func (c *Client) UpdateSettings(ctx context.Context, settings DeviceSettings) (DeviceSettings, error) { path := settingsPath resp := new(DeviceSettings) @@ -138,7 +157,7 @@ func (c *Client) UpdateSettings(ctx context.Context, settings DeviceSettings) (D return *resp, nil } -// ReplaceSettings replaces all settings +// ReplaceSettings replaces all settings. func (c *Client) ReplaceSettings(ctx context.Context, settings DeviceSettings) (DeviceSettings, error) { path := settingsPath resp := new(DeviceSettings) @@ -188,19 +207,27 @@ func (c *Client) DeleteWebhook(ctx context.Context, webhookID string) error { return nil } -// NewClient creates a new instance of the API Client. -func NewClient(config Config) *Client { - if config.BaseURL == "" { - config.BaseURL = BASE_URL +// GenerateToken generates a new access token with specified scopes and ttl. +// Returns the generated token details or an error if the request fails. +func (c *Client) GenerateToken(ctx context.Context, req TokenRequest) (TokenResponse, error) { + path := "/auth/token" + resp := new(TokenResponse) + + if err := c.Do(ctx, http.MethodPost, path, c.headers, &req, resp); err != nil { + return *resp, fmt.Errorf("failed to generate token: %w", err) } - return &Client{ - Client: rest.NewClient(rest.Config{ - Client: config.Client, - BaseURL: config.BaseURL, - }), - headers: map[string]string{ - "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(config.User+":"+config.Password)), - }, + return *resp, nil +} + +// RevokeToken revokes an access token with the specified jti (token ID). +// Returns an error if the revocation fails. +func (c *Client) RevokeToken(ctx context.Context, jti string) error { + path := fmt.Sprintf("/auth/token/%s", url.PathEscape(jti)) + + if err := c.Do(ctx, http.MethodDelete, path, c.headers, nil, nil); err != nil { + return fmt.Errorf("failed to revoke token: %w", err) } + + return nil } diff --git a/smsgateway/client_test.go b/smsgateway/client_test.go index eeabae7..e421665 100644 --- a/smsgateway/client_test.go +++ b/smsgateway/client_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "reflect" "testing" "time" @@ -19,11 +20,12 @@ const ( ) type mockServerExpectedInput struct { - method string - path string - query string - contentType string - body string + method string + path string + query string + authorization string + contentType string + body string } type mockServerOutput struct { @@ -32,6 +34,10 @@ type mockServerOutput struct { } func newMockServer(input mockServerExpectedInput, output mockServerOutput) *httptest.Server { + if input.authorization == "" { + input.authorization = authorizationHeader + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != input.method { w.WriteHeader(http.StatusMethodNotAllowed) @@ -48,7 +54,7 @@ func newMockServer(input mockServerExpectedInput, output mockServerOutput) *http return } - if r.Header.Get("Authorization") != authorizationHeader { + if r.Header.Get("Authorization") != input.authorization { w.WriteHeader(http.StatusUnauthorized) return } @@ -79,6 +85,43 @@ func newClient(baseURL string) *smsgateway.Client { }) } +func newJWTClient(baseURL string) *smsgateway.Client { + return smsgateway.NewClient(smsgateway.Config{ + BaseURL: baseURL, + Token: password, + }) +} + +func TestJWTClient_Send(t *testing.T) { + server := newMockServer(mockServerExpectedInput{ + method: http.MethodPost, + path: "/messages", + authorization: "Bearer " + password, + contentType: "application/json", + body: `{"textMessage":{"text":"Hello World!"},"phoneNumbers":["+1234567890"]}`, + }, mockServerOutput{ + code: http.StatusCreated, + body: `{}`, + }) + defer server.Close() + + client := newJWTClient(server.URL) + + t.Run("Success", func(t *testing.T) { + message := smsgateway.Message{ + TextMessage: &smsgateway.TextMessage{ + Text: "Hello World!", + }, + PhoneNumbers: []string{"+1234567890"}, + } + + _, err := client.Send(context.Background(), message) + if err != nil { + t.Errorf("Send() error = %v", err) + } + }) +} + func TestClient_Send(t *testing.T) { type args struct { ctx context.Context @@ -507,6 +550,7 @@ func TestClient_DeleteDevice(t *testing.T) { wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := tt.c.DeleteDevice(context.Background(), tt.args.deviceID); (err != nil) != tt.wantErr { @@ -517,308 +561,428 @@ func TestClient_DeleteDevice(t *testing.T) { } func TestClient_CheckHealth(t *testing.T) { - t.Run("Success", func(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodGet, - path: "/health", - }, mockServerOutput{ - code: http.StatusOK, - body: `{"checks":{"db:ping":{"description":"Failed sequential pings count","observedValue":0,"status":"pass"}},"releaseId":1117,"status":"pass","version":"v1.24.0"}`, - }) - defer server.Close() - - client := newClient(server.URL) - - health, err := client.CheckHealth(context.Background()) - if err != nil { - t.Fatalf("CheckHealth failed: %v", err) - } - if health.Status != "pass" { - t.Errorf("Expected status 'pass', got '%s'", health.Status) - } - }) + tests := []struct { + name string + code int + body string + want smsgateway.HealthResponse + wantErr bool + }{ + { + name: "Success", + code: http.StatusOK, + body: `{"status": "ok"}`, + want: smsgateway.HealthResponse{Status: "ok"}, + wantErr: false, + }, + { + name: "Error response", + code: http.StatusInternalServerError, + body: `{"error": "internal error"}`, + want: smsgateway.HealthResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newMockServer(mockServerExpectedInput{ + method: http.MethodGet, + path: "/health", + }, mockServerOutput{ + code: tt.code, + body: tt.body, + }) + defer server.Close() - t.Run("InternalError", func(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodGet, - path: "/health", - }, mockServerOutput{ - code: http.StatusInternalServerError, + client := newClient(server.URL) + resp, err := client.CheckHealth(context.Background()) + if (err != nil) != tt.wantErr { + t.Errorf("CheckHealth error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && !reflect.DeepEqual(resp, tt.want) { + t.Errorf("CheckHealth response = %v, want %v", resp, tt.want) + } }) - defer server.Close() - - client := newClient(server.URL) - - _, err := client.CheckHealth(context.Background()) - if err == nil { - t.Fatal("Expected error for internal server error") - } - }) + } } func TestClient_ExportInbox(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodPost, - path: "/inbox/export", - body: `{"deviceId":"dev1","since":"2024-01-01T00:00:00Z","until":"2024-01-02T00:00:00Z"}`, - }, mockServerOutput{ - code: http.StatusNoContent, - }) - defer server.Close() - - client := newClient(server.URL) - tests := []struct { name string - args smsgateway.MessagesExportRequest + req smsgateway.MessagesExportRequest + code int wantErr bool }{ { name: "Success", - args: smsgateway.MessagesExportRequest{ - DeviceID: "dev1", - Since: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - Until: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + req: smsgateway.MessagesExportRequest{ + DeviceID: "qTRWxZkF", + Since: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + Until: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), }, + code: http.StatusOK, wantErr: false, }, { - name: "Invalid request", - args: smsgateway.MessagesExportRequest{ - DeviceID: "dev1", - Since: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), - Until: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - }, + name: "Error response", + req: smsgateway.MessagesExportRequest{}, + code: http.StatusInternalServerError, wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := client.ExportInbox(context.Background(), tt.args); (err != nil) != tt.wantErr { - t.Errorf("Client.ExportInbox() error = %v, wantErr %v", err, tt.wantErr) + server := newMockServer(mockServerExpectedInput{ + method: http.MethodPost, + path: "/inbox/export", + contentType: "application/json", + body: `{"deviceId":"qTRWxZkF","since":"2023-01-01T00:00:00Z","until":"2023-01-02T00:00:00Z"}`, + }, mockServerOutput{ + code: tt.code, + body: `{}`, + }) + defer server.Close() + + client := newClient(server.URL) + err := client.ExportInbox(context.Background(), tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("ExportInbox error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestClient_GetLogs(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodGet, - path: "/logs", - query: "from=2024-01-01T00%3A00%3A00Z&to=2024-01-02T00%3A00%3A00Z", - }, mockServerOutput{ - code: http.StatusOK, - body: `[{"id":1,"message":"Test log"}]`, - }) - defer server.Close() - - client := newClient(server.URL) - + from := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) tests := []struct { - name string - args struct { - from time.Time - to time.Time - } + name string + code int + body string want []smsgateway.LogEntry wantErr bool }{ { name: "Success", - args: struct { - from time.Time - to time.Time - }{ - from: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - to: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), - }, + code: http.StatusOK, + body: `[{"createdAt":"2023-01-01T00:00:00Z","message":"test"}]`, want: []smsgateway.LogEntry{ { - ID: 1, - Message: "Test log", + Message: "test", + CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), }, }, wantErr: false, }, { - name: "Invalid request", - args: struct { - from time.Time - to time.Time - }{ - from: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), - to: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - }, + name: "Error response", + code: http.StatusInternalServerError, + body: `{"error": "internal error"}`, + want: nil, wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := client.GetLogs(context.Background(), tt.args.from, tt.args.to) + server := newMockServer(mockServerExpectedInput{ + method: http.MethodGet, + path: "/logs", + query: "from=" + url.QueryEscape( + from.Format(time.RFC3339), + ) + "&to=" + url.QueryEscape( + to.Format(time.RFC3339), + ), + }, mockServerOutput{ + code: tt.code, + body: tt.body, + }) + defer server.Close() + + client := newClient(server.URL) + logs, err := client.GetLogs(context.Background(), from, to) if (err != nil) != tt.wantErr { - t.Errorf("Client.GetLogs() error = %v, wantErr %v", err, tt.wantErr) - return + t.Errorf("GetLogs error = %v, wantErr %v", err, tt.wantErr) } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Client.GetLogs() got = %v, want %v", got, tt.want) + if !tt.wantErr && !reflect.DeepEqual(logs, tt.want) { + t.Errorf("GetLogs logs = %v, want %v", logs, tt.want) } }) } } func TestClient_GetSettings(t *testing.T) { - t.Run("Success", func(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodGet, - path: "/settings", - }, mockServerOutput{ + tests := []struct { + name string + code int + body string + want smsgateway.DeviceSettings + wantErr bool + }{ + { + name: "Success", code: http.StatusOK, - body: `{"messages":{"limit_period":"PerDay","limit_value":100}}`, - }) - defer server.Close() - - client := newClient(server.URL) + body: `{"messages":{"log_lifetime_days":30}}`, + want: smsgateway.DeviceSettings{ + Messages: &smsgateway.SettingsMessages{ + LogLifetimeDays: ptr(30), + }, + }, + wantErr: false, + }, + { + name: "Error response", + code: http.StatusInternalServerError, + body: `{"error": "internal error"}`, + want: smsgateway.DeviceSettings{}, + wantErr: true, + }, + } - settings, err := client.GetSettings(context.Background()) - if err != nil { - t.Fatalf("GetSettings failed: %v", err) - } - if *settings.Messages.LimitPeriod != smsgateway.PerDay { - t.Errorf("Expected limit period 'PerDay', got '%v'", *settings.Messages.LimitPeriod) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newMockServer(mockServerExpectedInput{ + method: http.MethodGet, + path: "/settings", + }, mockServerOutput{ + code: tt.code, + body: tt.body, + }) + defer server.Close() - t.Run("Error", func(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodGet, - path: "/settings", - }, mockServerOutput{ - code: http.StatusInternalServerError, + client := newClient(server.URL) + settings, err := client.GetSettings(context.Background()) + if (err != nil) != tt.wantErr { + t.Errorf("GetSettings error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && !reflect.DeepEqual(settings, tt.want) { + t.Errorf("GetSettings settings = %v, want %v", settings, tt.want) + } }) - defer server.Close() - - client := newClient(server.URL) - - _, err := client.GetSettings(context.Background()) - if err == nil { - t.Fatal("Expected error for internal server error") - } - }) + } } func TestClient_UpdateSettings(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodPatch, - path: "/settings", - contentType: "application/json", - body: `{"messages":{"limit_period":"PerHour","limit_value":50}}`, - }, mockServerOutput{ - code: http.StatusOK, - body: `{"messages":{"limit_period":"PerHour","limit_value":50}}`, - }) - defer server.Close() - - client := newClient(server.URL) - - limitPeriod := smsgateway.PerHour - limitValue := 50 tests := []struct { name string - args smsgateway.DeviceSettings - expected smsgateway.DeviceSettings + settings smsgateway.DeviceSettings + code int + body string + want smsgateway.DeviceSettings wantErr bool }{ { name: "Success", - args: smsgateway.DeviceSettings{ + settings: smsgateway.DeviceSettings{ Messages: &smsgateway.SettingsMessages{ - LimitPeriod: &limitPeriod, - LimitValue: &limitValue, + LogLifetimeDays: ptr(30), }, }, - expected: smsgateway.DeviceSettings{ + code: http.StatusOK, + body: `{"messages":{"log_lifetime_days":30}}`, + want: smsgateway.DeviceSettings{ Messages: &smsgateway.SettingsMessages{ - LimitPeriod: &limitPeriod, - LimitValue: &limitValue, + LogLifetimeDays: ptr(30), }, }, wantErr: false, }, { - name: "Error", - args: smsgateway.DeviceSettings{}, - expected: smsgateway.DeviceSettings{}, - wantErr: true, + name: "Error response", + settings: smsgateway.DeviceSettings{ + Messages: &smsgateway.SettingsMessages{ + LogLifetimeDays: ptr(30), + }, + }, + code: http.StatusInternalServerError, + body: `{"error": "internal error"}`, + want: smsgateway.DeviceSettings{}, + wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := client.UpdateSettings(context.Background(), tt.args) + server := newMockServer(mockServerExpectedInput{ + method: http.MethodPatch, + path: "/settings", + contentType: "application/json", + body: `{"messages":{"log_lifetime_days":30}}`, + }, mockServerOutput{ + code: tt.code, + body: tt.body, + }) + defer server.Close() + + client := newClient(server.URL) + resp, err := client.UpdateSettings(context.Background(), tt.settings) if (err != nil) != tt.wantErr { - t.Errorf("Client.UpdateSettings() error = %v, wantErr %v", err, tt.wantErr) - return + t.Errorf("UpdateSettings error = %v, wantErr %v", err, tt.wantErr) } - if !reflect.DeepEqual(got, tt.expected) { - t.Errorf("Client.UpdateSettings() got = %v, want %v", got, tt.expected) + if !tt.wantErr && !reflect.DeepEqual(resp, tt.want) { + t.Errorf("UpdateSettings response = %v, want %v", resp, tt.want) } }) } } func TestClient_ReplaceSettings(t *testing.T) { - server := newMockServer(mockServerExpectedInput{ - method: http.MethodPut, - path: "/settings", - contentType: "application/json", - body: `{"messages":{"limit_period":"PerHour","limit_value":50}}`, - }, mockServerOutput{ - code: http.StatusOK, - body: `{"messages":{"limit_period":"PerHour","limit_value":50}}`, - }) - defer server.Close() - - client := newClient(server.URL) - - limitPeriod := smsgateway.PerHour - limitValue := 50 tests := []struct { name string - args smsgateway.DeviceSettings - expected smsgateway.DeviceSettings + settings smsgateway.DeviceSettings + code int + body string + want smsgateway.DeviceSettings wantErr bool }{ { name: "Success", - args: smsgateway.DeviceSettings{ + settings: smsgateway.DeviceSettings{ + Messages: &smsgateway.SettingsMessages{ + LogLifetimeDays: ptr(30), + }, + }, + code: http.StatusOK, + body: `{"messages":{"log_lifetime_days":30}}`, + want: smsgateway.DeviceSettings{ Messages: &smsgateway.SettingsMessages{ - LimitPeriod: &limitPeriod, - LimitValue: &limitValue, + LogLifetimeDays: ptr(30), }, }, - expected: smsgateway.DeviceSettings{ + wantErr: false, + }, + { + name: "Error response", + settings: smsgateway.DeviceSettings{ Messages: &smsgateway.SettingsMessages{ - LimitPeriod: &limitPeriod, - LimitValue: &limitValue, + LogLifetimeDays: ptr(30), }, }, + code: http.StatusInternalServerError, + body: `{"error": "internal error"}`, + want: smsgateway.DeviceSettings{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newMockServer(mockServerExpectedInput{ + method: http.MethodPut, + path: "/settings", + contentType: "application/json", + body: `{"messages":{"log_lifetime_days":30}}`, + }, mockServerOutput{ + code: tt.code, + body: tt.body, + }) + defer server.Close() + + client := newClient(server.URL) + resp, err := client.ReplaceSettings(context.Background(), tt.settings) + if (err != nil) != tt.wantErr { + t.Errorf("ReplaceSettings error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && !reflect.DeepEqual(resp, tt.want) { + t.Errorf("ReplaceSettings response = %v, want %v", resp, tt.want) + } + }) + } +} + +func TestClient_GenerateToken(t *testing.T) { + tests := []struct { + name string + req smsgateway.TokenRequest + code int + body string + want smsgateway.TokenResponse + wantErr bool + }{ + { + name: "Success", + req: smsgateway.TokenRequest{Scopes: []string{"messages:read"}, TTL: 3600}, + code: http.StatusOK, + body: `{"id":"token_id_example","token_type":"Bearer","access_token":"access_token_example","expires_at":"2025-01-01T00:00:00Z"}`, + want: smsgateway.TokenResponse{ + ID: "token_id_example", + TokenType: "Bearer", + AccessToken: "access_token_example", + ExpiresAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, wantErr: false, }, { - name: "Error", - args: smsgateway.DeviceSettings{}, - expected: smsgateway.DeviceSettings{}, - wantErr: true, + name: "Error response", + req: smsgateway.TokenRequest{Scopes: []string{"messages:read"}, TTL: 3600}, + code: http.StatusInternalServerError, + body: `{"error": "internal error"}`, + want: smsgateway.TokenResponse{}, + wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := client.ReplaceSettings(context.Background(), tt.args) + server := newMockServer(mockServerExpectedInput{ + method: http.MethodPost, + path: "/auth/token", + contentType: "application/json", + body: `{"ttl":3600,"scopes":["messages:read"]}`, + }, mockServerOutput{ + code: tt.code, + body: tt.body, + }) + defer server.Close() + + client := newClient(server.URL) + resp, err := client.GenerateToken(context.Background(), tt.req) if (err != nil) != tt.wantErr { - t.Errorf("Client.UpdateSettings() error = %v, wantErr %v", err, tt.wantErr) - return + t.Errorf("GenerateToken error = %v, wantErr %v", err, tt.wantErr) } - if !reflect.DeepEqual(got, tt.expected) { - t.Errorf("Client.ReplaceSettings() got = %v, want %v", got, tt.expected) + if !tt.wantErr && !reflect.DeepEqual(resp, tt.want) { + t.Errorf("GenerateToken response = %v, want %v", resp, tt.want) + } + }) + } +} + +func TestClient_RevokeToken(t *testing.T) { + tests := []struct { + name string + jti string + code int + wantErr bool + }{ + { + name: "Success", + jti: "abc123", + code: http.StatusNoContent, + wantErr: false, + }, + { + name: "Error response", + jti: "abc123", + code: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newMockServer(mockServerExpectedInput{ + method: http.MethodDelete, + path: "/auth/token/abc123", + }, mockServerOutput{ + code: tt.code, + }) + defer server.Close() + + client := newClient(server.URL) + err := client.RevokeToken(context.Background(), tt.jti) + if (err != nil) != tt.wantErr { + t.Errorf("RevokeToken error = %v, wantErr %v", err, tt.wantErr) } }) } diff --git a/smsgateway/config.go b/smsgateway/config.go new file mode 100644 index 0000000..0e14b33 --- /dev/null +++ b/smsgateway/config.go @@ -0,0 +1,60 @@ +package smsgateway + +import ( + "fmt" + "net/http" +) + +type Config struct { + Client *http.Client // Optional HTTP Client, defaults to `http.DefaultClient` + BaseURL string // Optional base URL, defaults to `https://api.sms-gate.app/3rdparty/v1` + User string // Basic Auth username + Password string // Basic Auth password + Token string // Bearer token, has priority over Basic Auth +} + +// WithClient sets the HTTP client for the API client. +// If the client is nil, it defaults to `http.DefaultClient`. +// This is useful for testing or custom HTTP clients. +func (c Config) WithClient(client *http.Client) Config { + if client == nil { + client = http.DefaultClient + } + c.Client = client + return c +} + +// WithBaseURL sets the base URL for the API client. +// If the base URL is empty, it defaults to the constant `BaseURL`. +// This is useful for setting a custom base URL for the API client. +func (c Config) WithBaseURL(baseURL string) Config { + if baseURL == "" { + baseURL = BaseURL + } + c.BaseURL = baseURL + return c +} + +// WithJWTAuth sets the Bearer token for the API client. +// This is useful for setting a custom Bearer token for the API client. +// If the token is empty, it defaults to an empty string. +func (c Config) WithJWTAuth(token string) Config { + c.Token = token + return c +} + +// WithBasicAuth sets the Basic Auth credentials for the API client. +// This is useful for setting custom Basic Auth credentials for the API client. +// If the user or password is empty, it defaults to an empty string. +func (c Config) WithBasicAuth(user, password string) Config { + c.User = user + c.Password = password + return c +} + +func (c Config) Validate() error { + if c.User == "" && c.Password == "" && c.Token == "" { + return fmt.Errorf("%w: missing auth credentials", ErrInvalidConfig) + } + return nil +} diff --git a/smsgateway/config_test.go b/smsgateway/config_test.go new file mode 100644 index 0000000..0d31164 --- /dev/null +++ b/smsgateway/config_test.go @@ -0,0 +1,260 @@ +package smsgateway_test + +import ( + "net/http" + "testing" + + "github.com/android-sms-gateway/client-go/smsgateway" +) + +func TestConfig_WithClient(t *testing.T) { + client := &http.Client{} + tests := []struct { + name string + client *http.Client + expected *http.Client + }{ + { + name: "with custom client", + client: client, + expected: client, + }, + { + name: "with nil client should use default", + client: nil, + expected: http.DefaultClient, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := smsgateway.Config{} + result := config.WithClient(tt.client) + + if result.Client != tt.expected { + t.Errorf("WithClient() client = %v, want %v", result.Client, tt.expected) + } + }) + } +} + +func TestConfig_WithBaseURL(t *testing.T) { + tests := []struct { + name string + baseURL string + expected string + }{ + { + name: "with custom base URL", + baseURL: "https://custom.example.com/api", + expected: "https://custom.example.com/api", + }, + { + name: "with empty base URL should use default", + baseURL: "", + expected: smsgateway.BaseURL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := smsgateway.Config{} + result := config.WithBaseURL(tt.baseURL) + + if result.BaseURL != tt.expected { + t.Errorf("WithBaseURL() baseURL = %v, want %v", result.BaseURL, tt.expected) + } + }) + } +} + +func TestConfig_WithJWTAuth(t *testing.T) { + tests := []struct { + name string + token string + expected string + }{ + { + name: "with JWT token", + token: "jwt.token.here", + expected: "jwt.token.here", + }, + { + name: "with empty token", + token: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := smsgateway.Config{} + result := config.WithJWTAuth(tt.token) + + if result.Token != tt.expected { + t.Errorf("WithJWTAuth() token = %v, want %v", result.Token, tt.expected) + } + }) + } +} + +func TestConfig_WithBasicAuth(t *testing.T) { + tests := []struct { + name string + user string + password string + expected struct { + user string + password string + } + }{ + { + name: "with basic auth credentials", + user: "testuser", + password: "testpass", + expected: struct { + user string + password string + }{user: "testuser", password: "testpass"}, + }, + { + name: "with empty credentials", + user: "", + password: "", + expected: struct { + user string + password string + }{user: "", password: ""}, + }, + { + name: "with user only", + user: "testuser", + password: "", + expected: struct { + user string + password string + }{user: "testuser", password: ""}, + }, + { + name: "with password only", + user: "", + password: "testpass", + expected: struct { + user string + password string + }{user: "", password: "testpass"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := smsgateway.Config{} + result := config.WithBasicAuth(tt.user, tt.password) + + if result.User != tt.expected.user { + t.Errorf("WithBasicAuth() user = %v, want %v", result.User, tt.expected.user) + } + if result.Password != tt.expected.password { + t.Errorf("WithBasicAuth() password = %v, want %v", result.Password, tt.expected.password) + } + }) + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + config smsgateway.Config + expectError bool + errorMsg string + }{ + { + name: "valid config with basic auth", + config: smsgateway.Config{ + User: "testuser", + Password: "testpass", + }, + expectError: false, + }, + { + name: "valid config with JWT token", + config: smsgateway.Config{ + Token: "jwt.token.here", + }, + expectError: false, + }, + { + name: "valid config with both basic auth and token", + config: smsgateway.Config{ + User: "testuser", + Password: "testpass", + Token: "jwt.token.here", + }, + expectError: false, + }, + { + name: "valid config with user only", + config: smsgateway.Config{ + User: "testuser", + }, + expectError: false, + }, + { + name: "valid config with password only", + config: smsgateway.Config{ + Password: "testpass", + }, + expectError: false, + }, + { + name: "invalid config with no auth credentials", + config: smsgateway.Config{}, + expectError: true, + errorMsg: "invalid config: missing auth credentials", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + + if tt.expectError { + if err == nil { + t.Errorf("Validate() expected error but got none") + } else if err.Error() != tt.errorMsg { + t.Errorf("Validate() error = %v, want %v", err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("Validate() unexpected error = %v", err) + } + } + }) + } +} + +func TestConfig_Chaining(t *testing.T) { + // Test that methods can be chained together + customClient := &http.Client{} + config := smsgateway.Config{}. + WithClient(customClient). + WithBaseURL("https://custom.example.com/api"). + WithJWTAuth("jwt.token.here"). + WithBasicAuth("user", "pass") + + if config.Client != customClient { + t.Errorf("Chained WithClient() failed, got %v, want %v", config.Client, customClient) + } + if config.BaseURL != "https://custom.example.com/api" { + t.Errorf("Chained WithBaseURL() failed, got %v, want %v", config.BaseURL, "https://custom.example.com/api") + } + if config.Token != "jwt.token.here" { + t.Errorf("Chained WithJWTAuth() failed, got %v, want %v", config.Token, "jwt.token.here") + } + if config.User != "user" { + t.Errorf("Chained WithBasicAuth() user failed, got %v, want %v", config.User, "user") + } + if config.Password != "pass" { + t.Errorf("Chained WithBasicAuth() password failed, got %v, want %v", config.Password, "pass") + } +} diff --git a/smsgateway/domain_devices.go b/smsgateway/domain_devices.go index e48fbb8..6c04143 100644 --- a/smsgateway/domain_devices.go +++ b/smsgateway/domain_devices.go @@ -2,13 +2,25 @@ package smsgateway import "time" -// Device +// Device represents a device registered on the server. +// +// ID is the device ID, read only. +// +// Name is the device name. +// +// CreatedAt is the time at which the device was created, read only. +// +// UpdatedAt is the time at which the device was last updated, read only. +// +// DeletedAt is the time at which the device was deleted, read only. +// +// LastSeen is the time at which the device was last seen, read only. type Device struct { - ID string `json:"id" example:"PyDmBQZZXYmyxMwED8Fzy"` // ID - Name string `json:"name" example:"My Device"` // Name - CreatedAt time.Time `json:"createdAt" example:"2020-01-01T00:00:00Z"` // Created at (read only) - UpdatedAt time.Time `json:"updatedAt" example:"2020-01-01T00:00:00Z"` // Updated at (read only) - DeletedAt *time.Time `json:"deletedAt,omitempty" example:"2020-01-01T00:00:00Z"` // Deleted at (read only) + ID string `json:"id" example:"PyDmBQZZXYmyxMwED8Fzy"` // ID + Name string `json:"name" example:"My Device"` // Name + CreatedAt time.Time `json:"createdAt" example:"2020-01-01T00:00:00Z"` // Created at (read only) + UpdatedAt time.Time `json:"updatedAt" example:"2020-01-01T00:00:00Z"` // Updated at (read only) + DeletedAt *time.Time `json:"deletedAt,omitempty" example:"2020-01-01T00:00:00Z"` // Deleted at (read only) LastSeen time.Time `json:"lastSeen" example:"2020-01-01T00:00:00Z"` // Last seen at (read only) } diff --git a/smsgateway/domain_logs.go b/smsgateway/domain_logs.go index 5fbf2ee..8c422db 100644 --- a/smsgateway/domain_logs.go +++ b/smsgateway/domain_logs.go @@ -11,7 +11,7 @@ const ( LogEntryPriorityError LogEntryPriority = "ERROR" ) -// LogEntry represents a log entry +// LogEntry represents a log entry. type LogEntry struct { // A unique identifier for the log entry. ID uint64 `json:"id"` diff --git a/smsgateway/domain_messages.go b/smsgateway/domain_messages.go index 0231924..2e57eca 100644 --- a/smsgateway/domain_messages.go +++ b/smsgateway/domain_messages.go @@ -7,10 +7,9 @@ import ( ) type ( - // Processing state + // ProcessingState represents the state of a message. ProcessingState string - - // Message priority + // MessagePriority represents the priority of a message. MessagePriority int8 ) @@ -36,52 +35,58 @@ var allProcessStates = map[ProcessingState]struct{}{ ProcessingStateFailed: {}, } -// Text SMS message +// TextMessage represents an SMS message with a text body. +// +// Text is the message text. type TextMessage struct { - // Message text + // Text is the message text. Text string `json:"text" validate:"required,min=1,max=65535" example:"Hello World!"` } -// Data SMS message +// DataMessage represents an SMS message with a binary payload. +// +// Data is the base64-encoded payload. +// +// Port is the destination port. type DataMessage struct { - // Base64-encoded payload + // Data is the base64-encoded payload. Data string `json:"data" validate:"required,base64,min=4,max=65535" example:"SGVsbG8gV29ybGQh" format:"byte"` - // Destination port - Port uint16 `json:"port" validate:"required,min=1,max=65535" example:"53739"` + // Port is the destination port. + Port uint16 `json:"port" validate:"required,min=1,max=65535" example:"53739"` } -// Message +// Message represents an SMS message. +// +// ID is the message ID (if not set - will be generated). +// DeviceID is the optional device ID for explicit selection. +// Message is the message content (deprecated, use TextMessage instead). +// TextMessage is the text message. +// DataMessage is the data message. +// PhoneNumbers is the list of phone numbers. +// IsEncrypted is true if the message is encrypted. +// SimNumber is the SIM card number (1-3), if not set - default SIM will be used. +// WithDeliveryReport is true if the message should request a delivery report. +// Priority is the priority of the message, messages with values greater than `99` will bypass limits and delays. +// TTL is the time to live in seconds (conflicts with `ValidUntil`). +// ValidUntil is the time until the message is valid (conflicts with `TTL`). type Message struct { - // ID (if not set - will be generated) - ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` - // Optional device ID for explicit selection - DeviceID string `json:"deviceId,omitempty" validate:"omitempty,max=21" example:"PyDmBQZZXYmyxMwED8Fzy"` - - // Message content - // Deprecated: use TextMessage instead - Message string `json:"message,omitempty" validate:"omitempty,max=65535" example:"Hello World!"` - - // Text message - TextMessage *TextMessage `json:"textMessage,omitempty" validate:"omitempty"` - // Data message - DataMessage *DataMessage `json:"dataMessage,omitempty" validate:"omitempty"` - - // Recipients (phone numbers) - PhoneNumbers []string `json:"phoneNumbers" validate:"required,min=1,max=100,dive,required,min=1,max=128" example:"79990001234"` - // Is encrypted - IsEncrypted bool `json:"isEncrypted,omitempty" example:"true"` - - // SIM card number (1-3), if not set - default SIM will be used - SimNumber *uint8 `json:"simNumber,omitempty" validate:"omitempty,max=3" example:"1"` - // With delivery report - WithDeliveryReport *bool `json:"withDeliveryReport,omitempty" example:"true"` - // Priority, messages with values greater than `99` will bypass limits and delays - Priority MessagePriority `json:"priority,omitempty" validate:"omitempty,min=-128,max=127" example:"0" default:"0"` - - // Time to live in seconds (conflicts with `validUntil`) - TTL *uint64 `json:"ttl,omitempty" validate:"omitempty,min=5" example:"86400"` - // Valid until (conflicts with `ttl`) - ValidUntil *time.Time `json:"validUntil,omitempty" example:"2020-01-01T00:00:00Z"` + ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // ID (if not set - will be generated) + DeviceID string `json:"deviceId,omitempty" validate:"omitempty,max=21" example:"PyDmBQZZXYmyxMwED8Fzy"` // Optional device ID for explicit selection + + Message string `json:"message,omitempty" validate:"omitempty,max=65535" example:"Hello World!"` // Message content (deprecated, use TextMessage instead) + + TextMessage *TextMessage `json:"textMessage,omitempty" validate:"omitempty"` // Text message + DataMessage *DataMessage `json:"dataMessage,omitempty" validate:"omitempty"` // Data message + + PhoneNumbers []string `json:"phoneNumbers" validate:"required,min=1,max=100,dive,required,min=1,max=128" example:"79990001234"` // Recipients (phone numbers) + IsEncrypted bool `json:"isEncrypted,omitempty" example:"true"` // Is encrypted + + SimNumber *uint8 `json:"simNumber,omitempty" validate:"omitempty,max=3" example:"1"` // SIM card number (1-3), if not set - default SIM will be used + WithDeliveryReport *bool `json:"withDeliveryReport,omitempty" example:"true"` // With delivery report + Priority MessagePriority `json:"priority,omitempty" validate:"omitempty,min=-128,max=127" example:"0" default:"0"` // Priority, messages with values greater than `99` will bypass limits and delays + + TTL *uint64 `json:"ttl,omitempty" validate:"omitempty,min=5" example:"86400"` // Time to live in seconds (conflicts with `ValidUntil`) + ValidUntil *time.Time `json:"validUntil,omitempty" example:"2020-01-01T00:00:00Z"` // Valid until (conflicts with `TTL`) } // GetTextMessage returns the TextMessage, if it was set explicitly, or @@ -135,22 +140,21 @@ func (m *Message) Validate() error { return nil } -// Message state +// MessageState represents the state of a message. +// +// MessageState is a struct used to communicate the state of a message +// between the client and the server. It contains the message ID, device ID, +// state, and hashed and encrypted flags. Additionally, it contains a slice +// of RecipientState, representing the state of each recipient, and a map +// of states, representing the history of states for the message. type MessageState struct { - // Message ID - ID string `json:"id" validate:"required,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` - // Device ID - DeviceID string `json:"deviceId" validate:"required,max=21" example:"PyDmBQZZXYmyxMwED8Fzy"` - // State - State ProcessingState `json:"state" validate:"required" example:"Pending"` - // Hashed - IsHashed bool `json:"isHashed" example:"false"` - // Encrypted - IsEncrypted bool `json:"isEncrypted" example:"false"` - // Recipients states - Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` - // History of states - States map[string]time.Time `json:"states"` + ID string `json:"id" validate:"required,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Message ID + DeviceID string `json:"deviceId" validate:"required,max=21" example:"PyDmBQZZXYmyxMwED8Fzy"` // Device ID + State ProcessingState `json:"state" validate:"required" example:"Pending"` // State + IsHashed bool `json:"isHashed" example:"false"` // Hashed + IsEncrypted bool `json:"isEncrypted" example:"false"` // Encrypted + Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Recipients states + States map[string]time.Time `json:"states"` // History of states } func (m MessageState) Validate() error { @@ -163,12 +167,13 @@ func (m MessageState) Validate() error { return nil } -// Recipient state +// RecipientState represents the state of a recipient. +// +// RecipientState is a struct used to communicate the state of a recipient +// between the client and the server. It contains the phone number or first 16 +// symbols of the SHA256 hash, state, and error information. type RecipientState struct { - // Phone number or first 16 symbols of SHA256 hash - PhoneNumber string `json:"phoneNumber" validate:"required,min=1,max=128" example:"79990001234"` - // State - State ProcessingState `json:"state" validate:"required" example:"Pending"` - // Error (for `Failed` state) - Error *string `json:"error,omitempty" example:"timeout"` + PhoneNumber string `json:"phoneNumber" validate:"required,min=1,max=128" example:"79990001234"` // Phone number or first 16 symbols of SHA256 hash + State ProcessingState `json:"state" validate:"required" example:"Pending"` // State + Error *string `json:"error,omitempty" example:"timeout"` // Error (for `Failed` state) } diff --git a/smsgateway/domain_settings_test.go b/smsgateway/domain_settings_test.go index 995e5e3..4346e9b 100644 --- a/smsgateway/domain_settings_test.go +++ b/smsgateway/domain_settings_test.go @@ -90,7 +90,7 @@ func TestSettingsMessages_Validate(t *testing.T) { } } -// Helper function to create a pointer to an int +// Helper function to create a pointer to an int. func ptr(i int) *int { return &i } diff --git a/smsgateway/domain_upstream.go b/smsgateway/domain_upstream.go index 71d10ad..a3f9963 100644 --- a/smsgateway/domain_upstream.go +++ b/smsgateway/domain_upstream.go @@ -1,26 +1,21 @@ //nolint:lll // validator tags package smsgateway -// The type of event. +// PushEventType is the type of a push notification. type PushEventType string const ( - // A message is enqueued. - PushMessageEnqueued PushEventType = "MessageEnqueued" - // Webhooks are updated. - PushWebhooksUpdated PushEventType = "WebhooksUpdated" - // Messages export is requested. - PushMessagesExportRequested PushEventType = "MessagesExportRequested" - // Settings are updated. - PushSettingsUpdated PushEventType = "SettingsUpdated" + PushMessageEnqueued PushEventType = "MessageEnqueued" // Message is enqueued. + PushWebhooksUpdated PushEventType = "WebhooksUpdated" // Webhooks are updated. + PushMessagesExportRequested PushEventType = "MessagesExportRequested" // Messages export is requested. + PushSettingsUpdated PushEventType = "SettingsUpdated" // Settings are updated. ) -// A push notification. +// PushNotification represents a push notification. +// +// The token of the device that receives the notification. type PushNotification struct { - // The token of the device that receives the notification. - Token string `json:"token" validate:"required" example:"PyDmBQZZXYmyxMwED8Fzy"` - // The type of event. - Event PushEventType `json:"event" validate:"omitempty,oneof=MessageEnqueued WebhooksUpdated MessagesExportRequested SettingsUpdated" default:"MessageEnqueued" example:"MessageEnqueued"` - // The additional data associated with the event. - Data map[string]string `json:"data"` + Token string `json:"token" validate:"required" example:"PyDmBQZZXYmyxMwED8Fzy"` // The token of the device that receives the notification. + Event PushEventType `json:"event" validate:"oneof=MessageEnqueued WebhooksUpdated MessagesExportRequested SettingsUpdated" example:"MessageEnqueued"` // The type of event. + Data map[string]string `json:"data"` // The additional data associated with the event. } diff --git a/smsgateway/domain_webhooks.go b/smsgateway/domain_webhooks.go index 67f9969..5e203c0 100644 --- a/smsgateway/domain_webhooks.go +++ b/smsgateway/domain_webhooks.go @@ -8,20 +8,13 @@ import ( type WebhookEvent = string const ( - // Triggered when an SMS is received. - WebhookEventSmsReceived WebhookEvent = "sms:received" - // Triggered when a data SMS is received. - WebhookEventSmsDataReceived WebhookEvent = "sms:data-received" - // Triggered when an SMS is sent. - WebhookEventSmsSent WebhookEvent = "sms:sent" - // Triggered when an SMS is delivered. - WebhookEventSmsDelivered WebhookEvent = "sms:delivered" - // Triggered when an SMS processing fails. - WebhookEventSmsFailed WebhookEvent = "sms:failed" - // Triggered when the device pings the server. - WebhookEventSystemPing WebhookEvent = "system:ping" - // Triggered when an MMS is received. - WebhookEventMmsReceived WebhookEvent = "mms:received" + WebhookEventMmsReceived WebhookEvent = "mms:received" // Triggered when an MMS is received. + WebhookEventSmsDataReceived WebhookEvent = "sms:data-received" // Triggered when a data SMS is received. + WebhookEventSmsDelivered WebhookEvent = "sms:delivered" // Triggered when an SMS is delivered. + WebhookEventSmsFailed WebhookEvent = "sms:failed" // Triggered when an SMS processing fails. + WebhookEventSmsReceived WebhookEvent = "sms:received" // Triggered when an SMS is received. + WebhookEventSmsSent WebhookEvent = "sms:sent" // Triggered when an SMS is sent. + WebhookEventSystemPing WebhookEvent = "system:ping" // Triggered when the device pings the server. ) //nolint:gochecknoglobals // lookup table @@ -48,28 +41,27 @@ func WebhookEventTypes() []WebhookEvent { } } -// IsValid checks if the given event type is valid. -// -// e is the event type to be checked. -// Returns true if the event type is valid, false otherwise. +// IsValidWebhookEvent checks if the webhook event is a valid type. +// It takes a webhook event type and returns true if the event is valid, false otherwise. func IsValidWebhookEvent(e WebhookEvent) bool { _, ok := allEventTypes[e] return ok } -// A webhook configuration. +// Webhook represents a webhook configuration. +// +// ID is the unique identifier of the webhook. +// +// DeviceID is the unique identifier of the device the webhook is associated with. +// +// URL is the URL the webhook will be sent to. +// +// Event is the type of event the webhook is triggered for. type Webhook struct { - // The unique identifier of the webhook. - ID string `json:"id,omitempty" validate:"max=36" example:"123e4567-e89b-12d3-a456-426614174000"` - - // The unique identifier of the device the webhook is associated with. - DeviceID *string `json:"deviceId" validate:"omitempty,max=21" example:"PyDmBQZZXYmyxMwED8Fzy"` - - // The URL the webhook will be sent to. - URL string `json:"url" validate:"required,http_url" example:"https://example.com/webhook"` - - // The type of event the webhook is triggered for. - Event WebhookEvent `json:"event" validate:"required" example:"sms:received"` + ID string `json:"id,omitempty" validate:"max=36" example:"123e4567-e89b-12d3-a456-426614174000"` // The unique identifier of the webhook. + DeviceID *string `json:"deviceId" validate:"omitempty,max=21" example:"PyDmBQZZXYmyxMwED8Fzy"` // The unique identifier of the device the webhook is associated with. + URL string `json:"url" validate:"required,http_url" example:"https://example.com/webhook"` // The URL the webhook will be sent to. + Event WebhookEvent `json:"event" validate:"required" example:"sms:received"` // The type of event the webhook is triggered for. } // Validate checks if the webhook is configured correctly. diff --git a/smsgateway/dto_auth.go b/smsgateway/dto_auth.go new file mode 100644 index 0000000..4e439ee --- /dev/null +++ b/smsgateway/dto_auth.go @@ -0,0 +1,31 @@ +package smsgateway + +import "time" + +// TokenRequest represents a request to obtain an access token. +// +// The TTL field defines the requested lifetime of the access token in seconds. +// A value of 0 will result in a token with the maximum allowed lifetime. +// +// The Scopes field defines the scopes for which the access token is valid. +// At least one scope must be provided. +type TokenRequest struct { + TTL uint64 `json:"ttl,omitempty"` // lifetime of the access token in seconds + Scopes []string `json:"scopes" validate:"required,min=1,dive,required"` // scopes for which the access token is valid +} + +// TokenResponse represents a response to a TokenRequest. +// +// The ID field contains a unique identifier for the access token. +// +// The TokenType field contains the type of the access token, which is "Bearer". +// +// The AccessToken field contains the actual access token. +// +// The ExpiresAt field contains the time at which the access token is no longer valid. +type TokenResponse struct { + ID string `json:"id"` // unique identifier for the access token + TokenType string `json:"token_type"` // type of the access token + AccessToken string `json:"access_token"` // actual access token + ExpiresAt time.Time `json:"expires_at"` // time at which the access token is no longer valid +} diff --git a/smsgateway/types.go b/smsgateway/errors.go similarity index 74% rename from smsgateway/types.go rename to smsgateway/errors.go index 658884a..2b177ff 100644 --- a/smsgateway/types.go +++ b/smsgateway/errors.go @@ -3,6 +3,7 @@ package smsgateway import "errors" var ( - ErrValidationFailed = errors.New("validation failed") ErrConflictFields = errors.New("conflict fields") + ErrInvalidConfig = errors.New("invalid config") + ErrValidationFailed = errors.New("validation failed") ) diff --git a/smsgateway/requests.go b/smsgateway/requests.go index 7d86e45..e3a7ba4 100644 --- a/smsgateway/requests.go +++ b/smsgateway/requests.go @@ -2,15 +2,21 @@ package smsgateway import "time" -// Push request +// UpstreamPushRequest represents a request to push notifications. type UpstreamPushRequest = []PushNotification -// Messages export request +// MessagesExportRequest represents a request to export messages. +// +// DeviceID is the ID of the device to export messages for. +// +// Since is the start of the time range to export. +// +// Until is the end of the time range to export. type MessagesExportRequest struct { // DeviceID is the ID of the device to export messages for. DeviceID string `json:"deviceId" example:"PyDmBQZZXYmyxMwED8Fzy" validate:"required,max=21"` // Since is the start of the time range to export. - Since time.Time `json:"since" example:"2024-01-01T00:00:00Z" validate:"required,ltefield=Until"` + Since time.Time `json:"since" example:"2024-01-01T00:00:00Z" validate:"required,ltefield=Until"` // Until is the end of the time range to export. - Until time.Time `json:"until" example:"2024-01-01T23:59:59Z" validate:"required,gtefield=Since"` + Until time.Time `json:"until" example:"2024-01-01T23:59:59Z" validate:"required,gtefield=Since"` } diff --git a/smsgateway/requests_mobile.go b/smsgateway/requests_mobile.go index 3d46a73..ff0b587 100644 --- a/smsgateway/requests_mobile.go +++ b/smsgateway/requests_mobile.go @@ -2,39 +2,51 @@ package smsgateway import "time" -// Device registration request +// MobileRegisterRequest represents a request to register a mobile device. +// +// The Name field contains the name of the device, and the PushToken field +// contains the FCM token of the device. type MobileRegisterRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=128" example:"Android Phone"` // Device name - PushToken *string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` // FCM token + // Name of the device (optional) + // +optional + Name *string `json:"name,omitempty" validate:"omitempty,max=128" example:"Android Phone"` + // FCM token of the device (optional) + // +optional + PushToken *string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` } -// Device update request +// MobileUpdateRequest represents a request to update a mobile device. +// +// The Id field contains the device ID. +// +// The PushToken field contains the FCM token of the device. type MobileUpdateRequest struct { - //nolint:revive // backward compatibility - // ID - Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` - PushToken string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` // FCM token + //nolint:revive,staticcheck // backward compatibility + Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` // Device ID + PushToken string `json:"pushToken" example:"gHz-T6NezDlOfllr7F-Be" validate:"omitempty,max=256"` // FCM token of the device (optional) } -// Device change password request +// MobileChangePasswordRequest represents a request to change the password of a mobile device. +// +// The CurrentPassword field contains the current password of the device. +// +// The NewPassword field contains the new password of the device. It must be at least 14 characters long. type MobileChangePasswordRequest struct { - // Current password - CurrentPassword string `json:"currentPassword" validate:"required" example:"cp2pydvxd2zwpx"` - // New password, at least 14 characters - NewPassword string `json:"newPassword" validate:"required,min=14" example:"cp2pydvxd2zwpx"` + CurrentPassword string `json:"currentPassword" validate:"required" example:"cp2pydvxd2zwpx"` // Current password + NewPassword string `json:"newPassword" validate:"required,min=14" example:"cp2pydvxd2zwpx"` // New password, at least 14 characters } -// MobilePatchMessageItem represents a single message patch request +// MobilePatchMessageItem represents a single message patch request. type MobilePatchMessageItem struct { // Message ID - ID string `json:"id" validate:"required,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` + ID string `json:"id" validate:"required,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // State - State ProcessingState `json:"state" validate:"required" example:"Pending"` + State ProcessingState `json:"state" validate:"required" example:"Pending"` // Recipients states Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // History of states States map[string]time.Time `json:"states"` } -// Message patch request +// MobilePatchMessageRequest represents a request to patch messages. type MobilePatchMessageRequest []MobilePatchMessageItem diff --git a/smsgateway/responses.go b/smsgateway/responses.go index 3d3b4e8..d0e807e 100644 --- a/smsgateway/responses.go +++ b/smsgateway/responses.go @@ -1,8 +1,14 @@ package smsgateway -// Error response +// ErrorResponse represents a response to a request in case of an error. +// +// Message is an error message. +// +// Code is an error code, which is omitted if not specified. +// +// Data is an error context, which is omitted if not specified. type ErrorResponse struct { - Message string `json:"message" example:"An error occurred"` // Error message - Code int32 `json:"code,omitempty"` // Error code - Data any `json:"data,omitempty"` // Error context + Message string `json:"message" example:"An error occurred"` // Error message + Code int32 `json:"code,omitempty"` // Error code + Data any `json:"data,omitempty"` // Error context } diff --git a/smsgateway/responses_health.go b/smsgateway/responses_health.go index ff683be..2a2c7ad 100644 --- a/smsgateway/responses_health.go +++ b/smsgateway/responses_health.go @@ -8,7 +8,16 @@ const ( HealthStatusFail HealthStatus = "fail" ) -// Details of a health check. +// HealthCheck represents the result of a health check. +// +// Description is a human-readable description of the check. +// +// ObservedUnit is the unit of measurement for the observed value. +// +// ObservedValue is the observed value of the check. +// +// Status is the status of the check. +// It can be one of the following values: "pass", "warn", or "fail". type HealthCheck struct { // A human-readable description of the check. Description string `json:"description,omitempty"` @@ -21,10 +30,20 @@ type HealthCheck struct { Status HealthStatus `json:"status"` } -// Map of check names to their respective details. +// HealthChecks is a map of check names to their respective details. type HealthChecks map[string]HealthCheck -// Health status of the application. +// HealthResponse represents the result of a health check. +// +// Status is the overall status of the application. +// It can be one of the following values: "pass", "warn", or "fail". +// +// Version is the version of the application. +// +// ReleaseID is the release ID of the application. +// It is used to identify the version of the application. +// +// Checks is a map of check names to their respective details. type HealthResponse struct { // Overall status of the application. // It can be one of the following values: "pass", "warn", or "fail". diff --git a/smsgateway/responses_mobile.go b/smsgateway/responses_mobile.go index cd62391..1ff45f5 100644 --- a/smsgateway/responses_mobile.go +++ b/smsgateway/responses_mobile.go @@ -2,33 +2,47 @@ package smsgateway import "time" -// Device self-information response +// MobileDeviceResponse contains device information and external IP address. +// +// Device is empty if the device is not registered on the server. type MobileDeviceResponse struct { - Device *Device `json:"device,omitempty"` // Device information, empty if device is not registered on the server - ExternalIP string `json:"externalIp,omitempty"` // External IP + // Device information, empty if device is not registered on the server + Device *Device `json:"device,omitempty"` + // External IP address + ExternalIP string `json:"externalIp,omitempty"` } -// Device registration response +// MobileRegisterResponse contains device registration response. +// +// Id is the new device ID. +// Token is the device access token. +// Login is the user login. +// Password is the user password, empty for existing user. type MobileRegisterResponse struct { - //nolint:revive // backward compatibility - // New device ID - Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` - Token string `json:"token" example:"bP0ZdK6rC6hCYZSjzmqhQ"` // Device access token - Login string `json:"login" example:"VQ4GII"` // User login - Password string `json:"password,omitempty" example:"cp2pydvxd2zwpx"` // User password, empty for existing user + //nolint:revive,staticcheck // backward compatibility + Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` // New device ID + Token string `json:"token" example:"bP0ZdK6rC6hCYZSjzmqhQ"` // Device access token + Login string `json:"login" example:"VQ4GII"` // User login + Password string `json:"password,omitempty" example:"cp2pydvxd2zwpx"` // User password, empty for existing user } -// User one-time code response +// MobileUserCodeResponse represents a one-time code response for mobile clients. +// +// Code is the one-time code sent to the user. +// ValidUntil is the one-time code expiration time. type MobileUserCodeResponse struct { - Code string `json:"code" example:"123456"` // One-time code + Code string `json:"code" example:"123456"` // One-time code sent to the user ValidUntil time.Time `json:"validUntil" example:"2020-01-01T00:00:00Z"` // One-time code expiration time } -// MobileMessage represents a Message in mobile response format +// MobileMessage represents a message for mobile clients. +// +// It contains the message information and message creation time. type MobileMessage struct { - Message + Message // Message information + CreatedAt time.Time `json:"createdAt" example:"2020-01-01T00:00:00Z"` // Message creation time } -// MobileGetMessagesResponse represents a collection of messages for mobile clients +// MobileGetMessagesResponse represents a collection of messages for mobile clients. type MobileGetMessagesResponse []MobileMessage diff --git a/smsgateway/webhooks/webhook.go b/smsgateway/webhooks/webhook.go index 0096c07..c6cb518 100644 --- a/smsgateway/webhooks/webhook.go +++ b/smsgateway/webhooks/webhook.go @@ -2,7 +2,7 @@ package webhooks import "github.com/android-sms-gateway/client-go/smsgateway" -// Deprecated: use smsgateway package instead. +// EventType is deprecated: use smsgateway package instead. type EventType = smsgateway.WebhookEvent const ( @@ -23,5 +23,5 @@ func IsValidEventType(e EventType) bool { return smsgateway.IsValidWebhookEvent(e) } -// Deprecated: use smsgateway package instead. +// Webhook is deprecated: use smsgateway package instead. type Webhook = smsgateway.Webhook