diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100755 index 0000000..460480d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Create a report to help us improve this project +labels: bug-p3 +assignees: mrz1836 + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100755 index 0000000..cb3fd96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +labels: idea +assignees: mrz1836 + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100755 index 0000000..c7f749a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,13 @@ +--- +name: Question +about: 'General template for a question ' +labels: question +assignees: mrz1836 + +--- + +**What's your question?** +A clear and concise question using references to specific regions of code if applicable. + +**Additional context** +Add any other context or information. diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md new file mode 100755 index 0000000..4030f6f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/general.md @@ -0,0 +1,7 @@ +Fixes # + +## Proposed Changes + + - + - + - diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..bdbb1cd --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + - cron: '0 23 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['go'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.bak similarity index 100% rename from .github/workflows/main.yml rename to .github/workflows/main.bak diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..b27f13c --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,43 @@ +name: run-go-tests + +env: + GO111MODULE: on + +on: + pull_request: + branches: + - "*" + push: + branches: + - "*" + schedule: + - cron: '1 4 * * *' + +jobs: + test: + strategy: + matrix: + go-version: [ 1.13.x, 1.14.x, 1.15.x ] + os: [ ubuntu-latest ] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod # Module download cache + ~/.cache/go-build # Build cache (Linux) + ~/Library/Caches/go-build # Build cache (Mac) + '%LocalAppData%\go-build' # Build cache (Windows) + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run linter and tests + run: make test-ci + - name: Update code coverage + run: bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/.gitignore b/.gitignore index daf913b..ff70d48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,34 @@ -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll *.so +*.dylib -# Folders -_obj -_test +# Test binary, build with `go test -c` +*.test -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out +# Output of the go coverage tool, specifically when used with LiteIDE +*.out -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* +# OS files +*.db +*.DS_Store -_testmain.go +# Jetbrains +.idea/ -*.exe -*.test -*.prof +# Eclipse +.project + +# Notes +todo.md + +# Releases +*.tar.gz + +# Generated binaries +dist + +# Converage +coverage.txt \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..73ae37e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,464 @@ +# This file contains all available configuration options +# with their default values. + +# options for analysis running +run: + # default concurrency is a available CPU number + concurrency: 4 + + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 2m + + # exit code when at least one issue was found, default is 1 + issues-exit-code: 1 + + # include test files or not, default is true + tests: true + + # list of build tags, all linters use it. Default is empty list. + build-tags: + - mytag + + # which dirs to skip: issues from them won't be reported; + # can use regexp here: generated.*, regexp is applied on full path; + # default value is empty list, but default dirs are skipped independently + # from this option's value (see skip-dirs-use-default). + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + skip-dirs: + - .github + - .make + - dist + + # default is true. Enables skipping of directories: + # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ + skip-dirs-use-default: true + + # which files to skip: they will be analyzed, but issues from them + # won't be reported. Default value is empty list, but there is + # no need to include all autogenerated files, we confidently recognize + # autogenerated files. If it's not please let us know. + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + skip-files: + - ".*\\.my\\.go$" + - lib/bad.go + + # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": + # If invoked with -mod=readonly, the go command is disallowed from the implicit + # automatic updating of go.mod described above. Instead, it fails when any changes + # to go.mod are needed. This setting is most useful to check that go.mod does + # not need updates, such as in a continuous integration and testing system. + # If invoked with -mod=vendor, the go command assumes that the vendor + # directory holds the correct copies of dependencies and ignores + # the dependency descriptions in go.mod. + #modules-download-mode: readonly|release|vendor + + # Allow multiple parallel golangci-lint instances running. + # If false (default) - golangci-lint acquires file lock on start. + allow-parallel-runners: false + + +# output configuration options +output: + # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" + format: colored-line-number + + # print lines of code with issue, default is true + print-issued-lines: true + + # print linter name in the end of issue text, default is true + print-linter-name: true + + # make issues output unique by line, default is true + uniq-by-line: true + + # add a prefix to the output file references; default is no prefix + path-prefix: "" + + +# all available settings of specific linters +linters-settings: + dogsled: + # checks assignments with too many blank identifiers; default is 2 + max-blank-identifiers: 2 + dupl: + # tokens count to trigger issue, 150 by default + threshold: 100 + errcheck: + # report about not checking of errors in type assertions: `a := b.(MyStruct)`; + # default is false: such cases aren't reported by default. + check-type-assertions: false + + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; + # default is false: such cases aren't reported by default. + check-blank: false + + # [deprecated] comma-separated list of pairs of the form pkg:regex + # the regex is used to ignore names within pkg. (default "fmt:.*"). + # see https://github.com/kisielk/errcheck#the-deprecated-method for details + ignore: fmt:.*,io/ioutil:^Read.* + + # path to a file containing a list of functions to exclude from checking + # see https://github.com/kisielk/errcheck#excluding-functions for details + #exclude: /path/to/file.txt + exhaustive: + # indicates that switch statements are to be considered exhaustive if a + # 'default' case is present, even if all enum members aren't listed in the + # switch + default-signifies-exhaustive: false + funlen: + lines: 60 + statements: 40 + gci: + # put imports beginning with prefix after 3rd-party packages; + # only support one prefix + # if not set, use goimports.local-prefixes + local-prefixes: github.com/org/project + gocognit: + # minimal code complexity to report, 30 by default (but we recommend 10-20) + min-complexity: 10 + nestif: + # minimal complexity of if statements to report, 5 by default + min-complexity: 4 + goconst: + # minimal length of string constant, 3 by default + min-len: 3 + # minimal occurrences count to trigger, 3 by default + min-occurrences: 3 + gocritic: + # Which checks should be enabled; can't be combined with 'disabled-checks'; + # See https://go-critic.github.io/overview#checks-overview + # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` + # By default list of stable checks is used. + #enabled-checks: + # - rangeValCopy + + # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty + disabled-checks: + - regexpMust + + # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. + # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". + enabled-tags: + - performance + disabled-tags: + - experimental + + settings: # settings passed to gocritic + captLocal: # must be valid enabled check name + paramsOnly: true + rangeValCopy: + sizeThreshold: 32 + gocyclo: + # minimal code complexity to report, 30 by default (but we recommend 10-20) + min-complexity: 10 + godot: + # check all top-level comments, not only declarations + check-all: false + godox: + # report any comments starting with keywords, this is useful for TODO or FIXME comments that + # might be left in the code accidentally and should be resolved before merging + keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting + - NOTE + - OPTIMIZE # marks code that should be optimized before merging + - HACK # marks hack-arounds that should be removed before merging + gofmt: + # simplify code: gofmt with `-s` option, true by default + simplify: true + goheader: + values: + const: + # define here const type values in format k:v, for example: + # YEAR: 2020 + # COMPANY: MY COMPANY + regexp: + # define here regexp type values, for example + # AUTHOR: .*@mycompany\.com + template: + # put here copyright header template for source code files, for example: + # {{ AUTHOR }} {{ COMPANY }} {{ YEAR }} + # SPDX-License-Identifier: Apache-2.0 + # + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at: + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. + template-path: + # also as alternative of directive 'template' you may put the path to file with the template source + goimports: + # put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes + local-prefixes: github.com/org/project + golint: + # minimal confidence for issues, default is 0.8 + min-confidence: 0.8 + gomnd: + settings: + mnd: + # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. + checks: argument,case,condition,operation,return,assign + govet: + # report about shadowed variables + check-shadowing: true + + # settings per analyzer + settings: + printf: # analyzer name, run `go tool vet help` to see all analyzers + funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer + - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof + - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf + - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf + - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf + + # enable or disable analyzers by name + enable: + - atomicalign + enable-all: false + disable: + #- shadow + disable-all: false + depguard: + list-type: blacklist + include-go-root: false + packages: + - github.com/sirupsen/logrus + packages-with-error-message: + # specify an error message to output when a blacklisted package is used + - github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" + lll: + # max line length, lines longer will be reported. Default is 120. + # '\t' is counted as 1 character by default, and can be changed with the tab-width option + line-length: 120 + # tab width in spaces. Default to 1. + tab-width: 1 + maligned: + # print struct with more effective memory layout or not, false by default + suggest-new: true + misspell: + # Correct spellings using locale preferences for US or UK. + # Default is to use a neutral variety of English. + # Setting locale to US will correct the British spelling of 'colour' to 'color'. + locale: US + ignore-words: + - bsv + - bitcoin + nakedret: + # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 + max-func-lines: 30 + prealloc: + # XXX: we don't recommend using this linter before doing performance profiling. + # For most programs usage of prealloc will be a premature optimization. + + # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. + # True by default. + simple: true + range-loops: true # Report preallocation suggestions on range loops, true by default + for-loops: false # Report preallocation suggestions on for loops, false by default + nolintlint: + # Enable to ensure that nolint directives are all used. Default is true. + allow-unused: false + # Disable to ensure that nolint directives don't have a leading space. Default is true. + allow-leading-space: true + # Exclude following linters from requiring an explanation. Default is []. + allow-no-explanation: [] + # Enable to require an explanation of nonzero length after each nolint directive. Default is false. + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. Default is false. + require-specific: true + rowserrcheck: + packages: + - github.com/jmoiron/sqlx + testpackage: + # regexp pattern to skip files + skip-regexp: (export|internal)_test\.go + unparam: + # Inspect exported functions, default is false. Set to true if no external program/library imports your code. + # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: + # if it's called for subdir of a project it can't find external interfaces. All text editor integrations + # with golangci-lint call it on a directory with the changed file. + check-exported: false + unused: + # treat code as a program (not a library) and report unused exported identifiers; default is false. + # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: + # if it's called for subdir of a project it can't find funcs usages. All text editor integrations + # with golangci-lint call it on a directory with the changed file. + check-exported: false + whitespace: + multi-if: false # Enforces newlines (or comments) after every multi-line if statement + multi-func: false # Enforces newlines (or comments) after every multi-line function signature + wsl: + # If true append is only allowed to be cuddled if appending value is + # matching variables, fields or types on line above. Default is true. + strict-append: true + # Allow calls and assignments to be cuddled as long as the lines have any + # matching variables, fields or types. Default is true. + allow-assign-and-call: true + # Allow multiline assignments to be cuddled. Default is true. + allow-multiline-assign: true + # Allow declarations (var) to be cuddled. + allow-cuddle-declarations: true + # Allow trailing comments in ending of blocks + allow-trailing-comment: false + # Force newlines in end of case at this limit (0 = never). + force-case-trailing-whitespace: 0 + # Force cuddling of err checks with err var assignment + force-err-cuddling: false + # Allow leading comments to be separated with empty liens + allow-separated-leading-comment: false + gofumpt: + # Choose whether or not to use the extra rules that are disabled + # by default + extra-rules: false + + # The custom section can be used to define linter plugins to be loaded at runtime. See README doc + # for more info. + custom: + # Each custom linter should have a unique name. + #example: + # The path to the plugin *.so. Can be absolute or local. Required for each custom linter + #path: /path/to/example.so + # The description of the linter. Optional, just for documentation purposes. + #description: This is an example usage of a plugin linter. + # Intended to point to the repo location of the linter. Optional, just for documentation purposes. + #original-url: github.com/golangci/example-linter + +linters: + enable: + - megacheck + - govet + - gosec + - bodyclose + - golint + - unconvert + - dupl + - maligned + - misspell + - dogsled + - prealloc + - exportloopref + - exhaustive + - sqlclosecheck + - nolintlint + - gci + - goconst + - lll + disable: + - gocritic # use this for very opinionated linting + - gochecknoglobals + - whitespace + - wsl + - goerr113 + - godot + - testpackage + - nestif + - nlreturn + disable-all: false + presets: + - bugs + - unused + fast: false + + +issues: + # List of regexps of issue texts to exclude, empty list by default. + # But independently from this option we use default exclude patterns, + # it can be disabled by `exclude-use-default: false`. To list all + # excluded by default patterns execute `golangci-lint run --help` + exclude: + - Using the variable on range scope .* in function literal + + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - errcheck + - dupl + - gosec + - maligned + - lll + + # Exclude known linters from partially hard-vendored code, + # which is impossible to exclude via "nolint" comments. + - path: internal/hmac/ + text: "weak cryptographic primitive" + linters: + - gosec + + # Exclude some staticcheck messages + - linters: + - staticcheck + text: "SA1019:" + + # Exclude lll issues for long lines with go:generate + - linters: + - lll + source: "^//go:generate " + + # Independently from option `exclude` we use default exclude patterns, + # it can be disabled by this option. To list all + # excluded by default patterns execute `golangci-lint run --help`. + # Default value for this option is true. + exclude-use-default: false + + # The default value is false. If set to true exclude and exclude-rules + # regular expressions become case sensitive. + exclude-case-sensitive: false + + # The list of ids of default excludes to include or disable. By default it's empty. + include: + - EXC0002 # disable excluding of issues about comments from golint + + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 + + # Show only new issues: if there are unstaged changes or untracked files, + # only those changes are analyzed, else only changes in HEAD~ are analyzed. + # It's a super-useful option for integration of golangci-lint into existing + # large codebase. It's not practical to fix all existing issues at the moment + # of integration: much better don't allow issues in new code. + # Default is false. + new: false + + # Show only new issues created after git revision `REV` + new-from-rev: "" + + # Show only new issues created in git patch with set file path. + #new-from-patch: path/to/patch/file + +severity: + # Default value is empty string. + # Set the default severity for issues. If severity rules are defined and the issues + # do not match or no severity is provided to the rule this will be the default + # severity applied. Severities should match the supported severity names of the + # selected out format. + # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity + # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity + # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + default-severity: error + + # The default value is false. + # If set to true severity-rules regular expressions become case sensitive. + case-sensitive: false + + # Default value is empty list. + # When a list of severity rules are provided, severity information will be added to lint + # issues. Severity rules have the same filtering capability as exclude rules except you + # are allowed to specify one matcher per severity rule. + # Only affects out formats that support setting severity information. + rules: + - linters: + - dupl + severity: info \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..7580a49 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,27 @@ +# Make sure to check the documentation at http://goreleaser.com +# --------------------------- +# GENERAL +# --------------------------- +before: + hooks: + - make all +snapshot: + name_template: "{{ .Tag }}" +changelog: + sort: asc + filters: + exclude: + - '^.github:' + - '^test:' + +# --------------------------- +# BUILDER +# --------------------------- +build: + skip: true +# --------------------------- +# Github Release +# --------------------------- +release: + prerelease: true + name_template: "Release v{{.Version}}" \ No newline at end of file diff --git a/.make/common.mk b/.make/common.mk new file mode 100644 index 0000000..9b24ae7 --- /dev/null +++ b/.make/common.mk @@ -0,0 +1,72 @@ +## Default repository domain name +ifndef GIT_DOMAIN + override GIT_DOMAIN=github.com +endif + +## Set if defined (alias variable for ease of use) +ifdef branch + override REPO_BRANCH=$(branch) + export REPO_BRANCH +endif + +## Do we have git available? +HAS_GIT := $(shell command -v git 2> /dev/null) + +ifdef HAS_GIT + ## Do we have a repo? + HAS_REPO := $(shell git rev-parse --is-inside-work-tree 2> /dev/null) + ifdef HAS_REPO + ## Automatically detect the repo owner and repo name (for local use with Git) + REPO_NAME=$(shell basename "$(shell git rev-parse --show-toplevel 2> /dev/null)") + OWNER=$(shell git config --get remote.origin.url | sed 's/git@$(GIT_DOMAIN)://g' | sed 's/\/$(REPO_NAME).git//g') + REPO_OWNER=$(shell echo $(OWNER) | tr A-Z a-z) + VERSION_SHORT=$(shell git describe --tags --always --abbrev=0) + export REPO_NAME, REPO_OWNER, VERSION_SHORT + endif +endif + +## Set the distribution folder +ifndef DISTRIBUTIONS_DIR + override DISTRIBUTIONS_DIR=./dist +endif +export DISTRIBUTIONS_DIR + +help: ## Show this help message + @egrep -h '^(.+)\:\ ##\ (.+)' ${MAKEFILE_LIST} | column -t -c 2 -s ':#' + +release:: ## Full production release (creates release in Github) + @test $(github_token) + @export GITHUB_TOKEN=$(github_token) && goreleaser --rm-dist + +release-test: ## Full production test release (everything except deploy) + @goreleaser --skip-publish --rm-dist + +release-snap: ## Test the full release (build binaries) + @goreleaser --snapshot --skip-publish --rm-dist + +replace-version: ## Replaces the version in HTML/JS (pre-deploy) + @test $(version) + @test "$(path)" + @find $(path) -name "*.html" -type f -exec sed -i '' -e "s/{{version}}/$(version)/g" {} \; + @find $(path) -name "*.js" -type f -exec sed -i '' -e "s/{{version}}/$(version)/g" {} \; + +tag: ## Generate a new tag and push (tag version=0.0.0) + @test $(version) + @git tag -a v$(version) -m "Pending full release..." + @git push origin v$(version) + @git fetch --tags -f + +tag-remove: ## Remove a tag if found (tag-remove version=0.0.0) + @test $(version) + @git tag -d v$(version) + @git push --delete origin v$(version) + @git fetch --tags + +tag-update: ## Update an existing tag to current commit (tag-update version=0.0.0) + @test $(version) + @git push --force origin HEAD:refs/tags/v$(version) + @git fetch --tags -f + +update-releaser: ## Update the goreleaser application + @brew update + @brew upgrade goreleaser diff --git a/.make/go.mk b/.make/go.mk new file mode 100644 index 0000000..0113e1c --- /dev/null +++ b/.make/go.mk @@ -0,0 +1,97 @@ +## Default to the repo name if empty +ifndef BINARY_NAME + override BINARY_NAME=app +endif + +## Define the binary name +ifdef CUSTOM_BINARY_NAME + override BINARY_NAME=$(CUSTOM_BINARY_NAME) +endif + +## Set the binary release names +DARWIN=$(BINARY_NAME)-darwin +LINUX=$(BINARY_NAME)-linux +WINDOWS=$(BINARY_NAME)-windows.exe + +.PHONY: test lint vet install + +bench: ## Run all benchmarks in the Go application + @go test -bench=. -benchmem + +build-go: ## Build the Go application (locally) + @go build -o bin/$(BINARY_NAME) + +clean-mods: ## Remove all the Go mod cache + @go clean -modcache + +coverage: ## Shows the test coverage + @go test -coverprofile=coverage.out ./... && go tool cover -func=coverage.out + +godocs: ## Sync the latest tag with GoDocs + @test $(GIT_DOMAIN) + @test $(REPO_OWNER) + @test $(REPO_NAME) + @test $(VERSION_SHORT) + @curl https://proxy.golang.org/$(GIT_DOMAIN)/$(REPO_OWNER)/$(REPO_NAME)/@v/$(VERSION_SHORT).info + +install: ## Install the application + @go build -o $$GOPATH/bin/$(BINARY_NAME) + +install-go: ## Install the application (Using Native Go) + @go install $(GIT_DOMAIN)/$(REPO_OWNER)/$(REPO_NAME) + +lint: ## Run the golangci-lint application (install if not found) + @#Travis (has sudo) + @if [ "$(shell command -v golangci-lint)" = "" ] && [ $(TRAVIS) ]; then curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.39.0 && sudo cp ./bin/golangci-lint $(go env GOPATH)/bin/; fi; + @#AWS CodePipeline + @if [ "$(shell command -v golangci-lint)" = "" ] && [ "$(CODEBUILD_BUILD_ID)" != "" ]; then curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.39.0; fi; + @#Github Actions + @if [ "$(shell command -v golangci-lint)" = "" ] && [ "$(GITHUB_WORKFLOW)" != "" ]; then curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b $(go env GOPATH)/bin v1.39.0; fi; + @#Brew - MacOS + @if [ "$(shell command -v golangci-lint)" = "" ] && [ "$(shell command -v brew)" != "" ]; then brew install golangci-lint; fi; + @echo "running golangci-lint..." + @golangci-lint run --verbose + +test: ## Runs vet, lint and ALL tests + @$(MAKE) lint + @echo "running tests..." + @go test ./... -v + +test-short: ## Runs vet, lint and tests (excludes integration tests) + @$(MAKE) lint + @echo "running tests (short)..." + @go test ./... -v -test.short + +test-ci: ## Runs all tests via CI (exports coverage) + @$(MAKE) lint + @echo "running tests (CI)..." + @go test ./... -race -coverprofile=coverage.txt -covermode=atomic + +test-ci-no-race: ## Runs all tests via CI (no race) (exports coverage) + @$(MAKE) lint + @echo "running tests (CI - no race)..." + @go test ./... -coverprofile=coverage.txt -covermode=atomic + +test-ci-short: ## Runs unit tests via CI (exports coverage) + @$(MAKE) lint + @echo "running tests (CI - unit tests only)..." + @go test ./... -test.short -race -coverprofile=coverage.txt -covermode=atomic + +uninstall: ## Uninstall the application (and remove files) + @test $(BINARY_NAME) + @test $(GIT_DOMAIN) + @test $(REPO_OWNER) + @test $(REPO_NAME) + @go clean -i $(GIT_DOMAIN)/$(REPO_OWNER)/$(REPO_NAME) + @rm -rf $$GOPATH/src/$(GIT_DOMAIN)/$(REPO_OWNER)/$(REPO_NAME) + @rm -rf $$GOPATH/bin/$(BINARY_NAME) + +update: ## Update all project dependencies + @go get -u ./... && go mod tidy + +update-linter: ## Update the golangci-lint package (macOS only) + @brew upgrade golangci-lint + +vet: ## Run the Go vet application + @echo "running go vet..." + @go vet -v ./... \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eae3bb..a4f2257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## May 4, 2021 +### Changed +- Refactored library for our project's specific use (deviated from Official Library) +- Supporting resty and custom HTTP clients +- Supporting missing features from Official Library + ## March 24, 2021 ### Added - Support for EU region diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..be4bcd0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Code of Merit + +1. The project creators, lead developers, core team, constitute +the managing members of the project and have final say in every decision +of the project, technical or otherwise, including overruling previous decisions. +There are no limitations to this decisional power. + +2. Contributions are an expected result of your membership on the project. +Don't expect others to do your work or help you with your work forever. + +3. All members have the same opportunities to seek any challenge they want +within the project. + +4. Authority or position in the project will be proportional +to the accrued contribution. Seniority must be earned. + +5. Software is evolutive: the better implementations must supersede lesser +implementations. Technical advantage is the primary evaluation metric. + +6. This is a space for technical prowess; topics outside of the project +will not be tolerated. + +7. Non technical conflicts will be discussed in a separate space. Disruption +of the project will not be allowed. + +8. Individual characteristics, including but not limited to, +body, sex, sexual preference, race, language, religion, nationality, +or political preferences are irrelevant in the scope of the project and +will not be taken into account concerning your value or that of your contribution +to the project. + +9. Discuss or debate the idea, not the person. + +10. There is no room for ambiguity: Ambiguity will be met with questioning; +further ambiguity will be met with silence. It is the responsibility +of the originator to provide requested context. + +11. If something is illegal outside the scope of the project, it is illegal +in the scope of the project. This Code of Merit does not take precedence over +governing law. + +12. This Code of Merit governs the technical procedures of the project not the +activities outside of it. + +13. Participation on the project equates to agreement of this Code of Merit. + +14. No objectives beyond the stated objectives of this project are relevant +to the project. Any intent to deviate the project from its original purpose +of existence will constitute grounds for remedial action which may include +expulsion from the project. + +This document is the Code of Merit (`http://code-of-merit.org`), version 1.0. \ No newline at end of file diff --git a/CODE_STANDARDS.md b/CODE_STANDARDS.md new file mode 100644 index 0000000..5f0b66b --- /dev/null +++ b/CODE_STANDARDS.md @@ -0,0 +1,34 @@ +# Code Standards + +This project uses the following code standards and specifications from: +- [effective go](https://golang.org/doc/effective_go.html) +- [go benchmarks](https://golang.org/pkg/testing/#hdr-Benchmarks) +- [go examples](https://golang.org/pkg/testing/#hdr-Examples) +- [go tests](https://golang.org/pkg/testing/) +- [godoc](https://godoc.org/golang.org/x/tools/cmd/godoc) +- [gofmt](https://golang.org/cmd/gofmt/) +- [golangci-lint](https://golangci-lint.run/) +- [report card](https://goreportcard.com/) + +### *effective go* standards +View the [effective go](https://golang.org/doc/effective_go.html) standards documentation. + +### *golangci-lint* specifications +The package [golangci-lint](https://golangci-lint.run/usage/quick-start) runs several linters in one package/cmd. + +View the active linters in the [configuration file](.golangci.yml). + +Install via macOS: +```shell +brew install golangci-lint +``` + +Install via Linux and Windows: +```shell +# binary will be $(go env GOPATH)/bin/golangci-lint +curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.31.0 +golangci-lint --version +``` + +### *godoc* specifications +All code is written with documentation in mind. Follow the best practices with naming, examples and function descriptions. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c1c075 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# How to contribute + +Please send a GitHub Pull Request to with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). The more tests the merrier. Please follow the coding conventions (below) and make sure all of your commits are atomic (one feature per commit). + +## Testing + +All tests follow the standard Go testing pattern. +- [Go Tests](https://golang.org/pkg/testing/) +- [Go Examples](https://golang.org/pkg/testing/#hdr-Examples) +- [Go Benchmarks](https://golang.org/pkg/testing/#hdr-Benchmarks) + +## Coding conventions + +This project follows [effective Go standards](https://golang.org/doc/effective_go.html) and uses additional convention tools: +- [godoc](https://godoc.org/golang.org/x/tools/cmd/godoc) +- [golangci-lint](https://golangci-lint.run/) +- [GoReportCard.com](https://goreportcard.com/) \ No newline at end of file diff --git a/LICENSE b/LICENSE index 793dd2e..e96fa11 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2015 Customer IO +Copyright (c) 2021 MrZ1836 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d11e13 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +# Common makefile commands & variables between projects +include .make/common.mk + +# Common Golang makefile commands & variables between projects +include .make/go.mk + +## Not defined? Use default repo name which is the application +ifeq ($(REPO_NAME),) + REPO_NAME="go-customerio" +endif + +## Not defined? Use default repo owner +ifeq ($(REPO_OWNER),) + REPO_OWNER="mrz1836" +endif + +.PHONY: clean + +all: ## Runs multiple commands + @$(MAKE) test + +clean: ## Remove previous builds and any test cache data + @go clean -cache -testcache -i -r + @test $(DISTRIBUTIONS_DIR) + @if [ -d $(DISTRIBUTIONS_DIR) ]; then rm -r $(DISTRIBUTIONS_DIR); fi + +release:: ## Runs common.release then runs godocs + @$(MAKE) godocs \ No newline at end of file diff --git a/README.md b/README.md index 199f202..3391aab 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,161 @@ -# Customerio - -# go-customerio [![CircleCI](https://circleci.com/gh/customerio/go-customerio/tree/master.svg?style=svg)](https://circleci.com/gh/customerio/go-customerio/tree/master) - -A golang client for the [Customer.io](http://customer.io) [event API](https://app.customer.io/api/docs/index.html). -_Tested with Go1.12_ - -Godoc here: [https://godoc.org/github.com/customerio/go-customerio](https://godoc.org/github.com/customerio/go-customerio) +# CustomerIO (Unofficial Fork) +> A golang client for the [Customer.io](https://customer.io) [event API](https://customer.io/docs/api/#section/Overview). [(Visit the Original repo/library)](https://github.com/customerio/go-customerio) + +[![Release](https://img.shields.io/github/release-pre/mrz1836/go-customerio.svg?logo=github&style=flat&v=3)](https://github.com/mrz1836/go-customerio/releases) +[![Build Status](https://img.shields.io/github/workflow/status/mrz1836/go-customerio/run-go-tests?logo=github&v=3)](https://github.com/mrz1836/go-customerio/actions) +[![Report](https://goreportcard.com/badge/github.com/mrz1836/go-customerio?style=flat&v=3)](https://goreportcard.com/report/github.com/mrz1836/go-customerio) +[![codecov](https://codecov.io/gh/mrz1836/go-customerio/branch/master/graph/badge.svg?v=3)](https://codecov.io/gh/mrz1836/go-customerio) +[![Go](https://img.shields.io/github/go-mod/go-version/mrz1836/go-customerio?v=3)](https://golang.org/) + +
+ +## Table of Contents +- [Installation](#installation) +- [Documentation](#documentation) +- [Examples & Tests](#examples--tests) +- [Benchmarks](#benchmarks) +- [Code Standards](#code-standards) +- [Usage](#usage) +- [Maintainers](#maintainers-of-the-fork) +- [Contributing](#contributing) +- [License](#license) + +
## Installation -Add this line to your application's imports: - -```go -import ( - // ... - "github.com/customerio/go-customerio" -) +**go-customerio** requires a [supported release of Go](https://golang.org/doc/devel/release.html#policy). +```shell script +go get -u github.com/mrz8136/go-customerio ``` -And then execute: - - go get - -Or install it yourself: - - $ go get "github.com/customerio/go-customerio" - -## Usage - -### Before we get started: API client vs. JavaScript snippet - -It's helpful to know that everything below can also be accomplished -through the [Customer.io JavaScript snippet](http://customer.io/docs/basic-integration.html). +
+ +## Documentation +View the generated [documentation](https://pkg.go.dev/github.com/mrz8136/go-customerio) + +[![GoDoc](https://godoc.org/github.com/mrz8136/go-customerio?status.svg&style=flat)](https://pkg.go.dev/github.com/mrz8136/go-customerio) + +### Features +- [Client](client.go) is completely configurable +- Using default [heimdall http client](https://github.com/gojek/heimdall) with exponential backoff & more +- Use your own custom HTTP client +- Current coverage for the [customer.io API](https://customer.io/docs/api/#section/Overview) + - [x] Authentication + - [x] Find your account region + - [x] Test Track API keys + - [ ] Customers + - [x] Add or update a customer + - [x] Delete a customer + - [x] Add or update a customer device + - [x] Delete a customer device + - [ ] Suppress a customer profile + - [ ] Unsuppress a customer profile + - [ ] Custom unsubscribe handling + - [ ] Events + - [x] Track a customer event + - [x] Track an anonymous event + - [ ] Report push metrics + - [x] Transactional Emails + - [x] Send a transactional email + - [ ] Trigger Broadcasts + - [ ] Trigger a broadcast + - [ ] Get the status of a broadcast + - [ ] List errors from a broadcast + - [ ] **Beta API** (Customers) + - [ ] Get customers by email + - [ ] Search for customers + - [ ] Lookup a customer's attributes + - [ ] List customers and attributes + - [ ] Lookup a customer's segments + - [ ] Lookup messages sent to a customer + - [ ] Lookup a customer's activities + - [ ] **Beta API** (Campaigns) + - [ ] List campaigns + - [ ] Get a campaign + - [ ] Get campaign metrics + - [ ] Get campaign link metrics + - [ ] List campaign actions + - [ ] Get campaign message metadata + - [ ] Get a campaign action + - [ ] Update a campaign action + - [ ] Get campaign action metrics + - [ ] Get link metrics for an action + - [ ] **Beta API** (Newsletters) + - [ ] List newsletters + - [ ] Get a newsletter + - [ ] Get newsletter metrics + - [ ] Get newsletter link metrics + - [ ] List newsletter variants + - [ ] Get newsletter message metadata + - [ ] Get a newsletter variant + - [ ] Update a newsletter variant + - [ ] Get metrics for a variant + - [ ] Get newsletter variant link metrics + - [ ] **Beta API** (Segments) + - [ ] Create a manual segment + - [ ] List segments + - [ ] Get a segment + - [ ] Delete a segment + - [ ] Get a segment's dependencies + - [ ] Get a segment customer count + - [ ] List customers in a segment + - [ ] **Beta API** (Messages) + - [ ] List messages + - [ ] Get a message + - [ ] Get an archived message + - [ ] **Beta API** (Exports) + - [ ] List exports + - [ ] Get an export + - [ ] Download an export + - [ ] Export customer data + - [ ] Export information about deliveries + - [ ] **Beta API** (Activities) + - [ ] List activities + - [ ] **Beta API** (Collections) + - [ ] Create a collection + - [ ] List your collections + - [ ] Lookup a collection + - [ ] Delete a collection + - [ ] Update a collection + - [ ] Lookup collection contents + - [ ] Update the contents of a collection + - [ ] **Beta API** (Sender Identities) + - [ ] List sender identities + - [ ] Get a sender + - [ ] Get sender usage data + - [ ] **Beta API** (Reporting Webhooks) + - [ ] Create a reporting webhook + - [ ] List reporting webhooks + - [ ] Get a reporting webhook + - [ ] Update a webhook configuration + - [ ] Delete a reporting webhook + - [ ] Reporting webhook format + - [ ] **Beta API** (Broadcasts) + - [ ] List broadcasts + - [ ] Get a broadcast + - [ ] Get metrics for a broadcast + - [ ] Get broadcast link metrics + - [ ] List broadcast actions + - [ ] Get message metadata for a broadcast + - [ ] Get a broadcast action + - [ ] Update a broadcast action + - [ ] Get broadcast action metrics + - [ ] Get broadcast action link metrics + - [ ] Get broadcast triggers + - [ ] **Beta API** (Snippets) + - [ ] List snippets + - [ ] Update snippets + - [ ] Delete a snippet + - [ ] **Beta API** (Info) + - [ ] List IP addresses + +
+Before we get started: API client vs. JavaScript snippet +
+ +It's helpful to know that everything (`Tracking API`) below can also be accomplished +through the [Customer.io JavaScript snippet](https://customer.io/docs/api/#tag/trackJsOrBackend). In many cases, using the JavaScript snippet will be easier to integrate with your app, but there are several reasons why using the API client is useful: @@ -43,25 +167,38 @@ your app, but there are several reasons why using the API client is useful: - You'd rather not have another javascript snippet slowing down your frontend. Our snippet is asynchronous (doesn't affect initial page load) and very small, but we understand. -In the end, the decision on whether or not to use the API client or +In the end, the decision on whether to use the API client, or the JavaScript snippet should be based on what works best for you. -You'll be able to integrate **fully** with [Customer.io](http://customer.io) with either approach. +You'll be able to integrate **fully** with [Customer.io](https://customer.io) with either approach. +
-### Setup +
+Basic Setup +
Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials). ```go -track := customerio.NewTrackClient("YOUR SITE ID", "YOUR API SECRET KEY", customerio.WithRegion(customerio.RegionUS)) +client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + customerio.WithRegion(customerio.RegionUS), +) ``` -Your account region—`RegionUS` or `RegionEU`—is optional. If you do not specify your region, we assume that your account is based in the US (`RegionUS`). If your account is based in the EU and you do not provide the correct region, we'll route requests from the US to `RegionEU` accordingly, however this may cause data to be logged in the US. +Your account region—`RegionUS` or `RegionEU`—is optional. If you do not specify your region, +we assume that your account is based in the US (`RegionUS`). If your account is based in the +EU and you do not provide the correct region, we'll route requests from the US to `RegionEU` accordingly, +however this may cause data to be logged in the US. + +
-### Identify logged in customers +
+Add or Update logged in customers +
-Tracking data of logged in customers is a key part of [Customer.io](http://customer.io). In order to +Tracking data of logged in customers is a key part of [Customer.io](https://customer.io). In order to send triggered emails, we must know the email address of the customer. You can -also specify any number of customer attributes which help tailor [Customer.io](http://customer.io) to your +also specify any number of customer attributes which help tailor [Customer.io](https://customer.io) to your business. Attributes you specify are useful in several ways: @@ -76,7 +213,7 @@ Attributes you specify are useful in several ways: particular plan (e.g. "premium"). You'll want to identify your customers when they sign up for your app and any time their -key information changes. This keeps [Customer.io](http://customer.io) up to date with your customer information. +key information changes. This keeps [Customer.io](https://customer.io) up to date with your customer information. ```go // Arguments @@ -84,20 +221,23 @@ key information changes. This keeps [Customer.io](http://customer.io) up to date // attributes (required) - a ```map[string]interface{}``` of information about the customer. You can pass any // information that would be useful in your triggers. You // should at least pass in an email, and created_at timestamp. -// your interface{} should be parseable as Json by 'encoding/json'.Marshal +// your interface{} should be parsable as Json by 'encoding/json'.Marshal -track.Identify("5", map[string]interface{}{ - "email": "bob@example.com", +err = client.UpdateCustomer("123", map[string]interface{}{ "created_at": time.Now().Unix(), + "email": "bob@example.com", "first_name": "Bob", - "plan": "basic", + "plan": "basic", }) ``` +
-### Deleting customers +
+Deleting customers +
Deleting a customer will remove them, and all their information from -Customer.io. Note: if you're still sending data to Customer.io via +[Customer.io](https://customer.io). Note: if you're still sending data to [Customer.io](https://customer.io) via other means (such as the javascript snippet), the customer could be recreated. @@ -105,14 +245,17 @@ recreated. // Arguments // customerID (required) - a unique identifier for the customer. This // should be the same id you'd pass into the -// `identify` command above. +// `UpdateCustomer` command above. -track.Delete("5") +client.DeleteCustomer("5") ``` +
-### Tracking a custom event +
+Tracking a custom event +
-Now that you're identifying your customers with [Customer.io](http://customer.io), you can now send events like +Now that you're identifying your customers with [Customer.io](https://customer.io), you can now send events like "purchased" or "watchedIntroVideo". These allow you to more specifically target your users with automated emails, and track conversions when you're sending automated emails to encourage your customers to perform an action. @@ -121,89 +264,105 @@ encourage your customers to perform an action. // Arguments // customerID (required) - the id of the customer who you want to associate with the event. // name (required) - the name of the event you want to track. +// timestamp (optional) - used for sending events in the past // attributes (optional) - any related information you'd like to attach to this // event, as a ```map[string]interface{}```. These attributes can be used in your triggers to control who should // receive the triggered email. You can set any number of data values. -track.Track("5", "purchase", map[string]interface{}{ - "type": "socks", - "price": "13.99", +client.NewEvent("5", "purchase", time.Now().UTC(), map[string]interface{}{ +"type": "socks", +"price": "13.99", }) ``` +
-### Tracking an anonymous event +
+Tracking an Anonymous Event +
-[Anonymous -events](https://learn.customer.io/recipes/anonymous-invite-emails.html) are +[Anonymous events](https://learn.customer.io/recipes/anonymous-invite-emails.html) are also supported. These are ideal for when you need to track an event for a customer which may not exist in your People list. ```go // Arguments // name (required) - the name of the event you want to track. +// timestamp (optional) - used for sending events in the past // attributes (optional) - any related information you'd like to attach to this // event, as a ```map[string]interface{}```. These attributes can be used in your triggers to control who should // receive the triggered email. You can set any number of data values. -track.TrackAnonymous("invite", map[string]interface{}{ +client.NewAnonymousEvent("invite", time.Now().UTC(), map[string]interface{}{ "first_name": "Alex", "source": "OldApp", }) ``` +
-### Adding a device to a customer +
+Adding a device to a customer +
In order to send push notifications, we need customer device information. ```go // Arguments -// customerID (required) - a unique identifier string for this customer -// deviceID (required) - a unique identifier string for this device -// platform (required) - the platform of the device, currently only accepts 'ios' and 'andriod' -// data (optional) - a ```map[string]interface{}``` of information about the device. You can pass any -// key/value pairs that would be useful in your triggers. We -// currently only save 'last_used'. -// your interface{} should be parseable as Json by 'encoding/json'.Marshal - -track.AddDevice("5", "messaging token", "android", map[string]interface{}{ -"last_used": time.Now().Unix(), +// customerID (required) - a unique identifier string for this customer +// device.ID (required) - a unique identifier string for this device +// device.Platform (required) - the platform of the device, currently only accepts 'ios' and 'andriod' +// device.LastUsed (optional) - the timestamp the device was last used + +client.UpdateDevice("5", &customerio.Device{ + ID: "1234567890", + LastUsed: time.Now().UTC().Unix(), + Platform: customerio.PlatformIOs, }) ``` +
-### Deleting devices +
+Deleting devices +
-Deleting a device will remove it from the customers device list in Customer.io. +Deleting a device will remove it from the customers' device list in Customer.io. ```go // Arguments // customerID (required) - the id of the customer the device you want to delete belongs to -// deviceToken (required) - a unique identifier for the device. This +// deviceID (required) - a unique identifier for the device. This // should be the same id you'd pass into the -// `addDevice` command above +// `UpdateDevice` command above -track.DeleteDevice("5", "messaging-token") +client.DeleteDevice("5", "1234567890") ``` +
-### Send Transactional Messages +
+Send Transactional Messages +
-To use the Customer.io [Transactional API](https://customer.io/docs/transactional-api), create an instance of the API client using an [app key](https://customer.io/docs/managing-credentials#app-api-keys). +To use the Customer.io [Transactional API](https://customer.io/docs/transactional-api), create an instance +of the API client using an [app key](https://customer.io/docs/managing-credentials#app-api-keys). -Create a `SendEmailRequest` instance, and then use `SendEmail` to send your message. [Learn more about transactional messages and optional `SendEmailRequest` properties](https://customer.io/docs/transactional-api). +Create a `SendEmailRequest` instance, and then use `SendEmail` to send your message. +[Learn more about transactional messages and optional `SendEmailRequest` properties](https://customer.io/docs/transactional-api). You can also send attachments with your message. Use `Attach` to encode attachments. ```go -import "github.com/customerio/go-customerio" - -client := customerio.NewAPIClient("", customerio.WithRegion(customerio.RegionUS)); +import "github.com/mrz8136/go-customerio" +client, err := customerio.NewClient( + customerio.WithAppKey(os.Getenv("APP_API_KEY")), + customerio.WithRegion(customerio.RegionUS), +) // TransactionalMessageId — the ID of the transactional message you want to send. // To — the email address of your recipients. // Identifiers — contains the id of your recipient. If the id does not exist, Customer.io creates it. // MessageData — contains properties that you want reference in your message using liquid. // Attach — a helper that encodes attachments to your message. -request := customerio.SendEmailRequest{ +request := client.SendEmailRequest{ To: "person@example.com", TransactionalMessageID: "3", MessageData: map[string]interface{}{ @@ -233,12 +392,114 @@ if err != nil { fmt.Println(body) ``` +
+ +
+Library Deployment +
+ +[goreleaser](https://github.com/goreleaser/goreleaser) for easy binary or library deployment to Github and can be installed via: `brew install goreleaser`. + +The [.goreleaser.yml](.goreleaser.yml) file is used to configure [goreleaser](https://github.com/goreleaser/goreleaser). + +Use `make release-snap` to create a snapshot version of the release, and finally `make release` to ship to production. +
+ +
+Makefile Commands +
+ +View all `makefile` commands +```shell script +make help +``` + +List of all current commands: +```text +all Runs multiple commands +clean Remove previous builds and any test cache data +clean-mods Remove all the Go mod cache +coverage Shows the test coverage +godocs Sync the latest tag with GoDocs +help Show this help message +install Install the application +install-go Install the application (Using Native Go) +lint Run the golangci-lint application (install if not found) +release Full production release (creates release in Github) +release Runs common.release then runs godocs +release-snap Test the full release (build binaries) +release-test Full production test release (everything except deploy) +replace-version Replaces the version in HTML/JS (pre-deploy) +tag Generate a new tag and push (tag version=0.0.0) +tag-remove Remove a tag if found (tag-remove version=0.0.0) +tag-update Update an existing tag to current commit (tag-update version=0.0.0) +test Runs vet, lint and ALL tests +test-ci Runs all tests via CI (exports coverage) +test-ci-no-race Runs all tests via CI (no race) (exports coverage) +test-ci-short Runs unit tests via CI (exports coverage) +test-short Runs vet, lint and tests (excludes integration tests) +uninstall Uninstall the application (and remove files) +update-linter Update the golangci-lint package (macOS only) +vet Run the Go vet application +``` +
+ +
+ +## Examples & Tests +All unit tests and [examples](examples) run via [Github Actions](https://github.com/mrz8136/go-customerio/actions) and +uses [Go version(s) 1.14.x and 1.15.x](https://golang.org/doc/go1.15). View the [configuration file](.github/workflows/run-tests.yml). + +Run all tests (including integration tests) +```shell script +make test +``` + +Run tests (excluding integration tests) +```shell script +make test-short +``` + +
+ +## Benchmarks +Run the Go benchmarks: +```shell script +make bench +``` + +
+ +## Code Standards +Read more about this Go project's [code standards](CODE_STANDARDS.md). + +
+ +## Usage +Checkout all the [examples](examples)! + +
+ +## Maintainers (of the Fork) +This is an "unofficial fork" of the [official library](https://github.com/customerio/go-customerio) and was +created to enhance or improve missing functionality. + +| [MrZ](https://github.com/mrz1836) | +|:---:| +| [MrZ](https://github.com/mrz1836) | + +
## Contributing -1. Fork it -2. Clone your fork (`git clone git@github.com:MY_USERNAME/go-customerio.git && cd go-customerio`) -3. Create your feature branch (`git checkout -b my-new-feature`) -4. Commit your changes (`git commit -am 'Added some feature'`) -5. Push to the branch (`git push origin my-new-feature`) -6. Create new Pull Request +View the [contributing guidelines](CONTRIBUTING.md) and follow the [code of conduct](CODE_OF_CONDUCT.md). + +### How can I help? +All kinds of contributions are welcome :raised_hands:! +The most basic way to show your support is to star :star2: the project, or to raise issues :speech_balloon:. +You can also support this project by [becoming a sponsor on GitHub](https://github.com/sponsors/mrz8136) :clap:! +
+ +## License + +![License](https://img.shields.io/github/license/mrz8136/go-customerio.svg?style=flat&v=3) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bc9eb61 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported & Maintained Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.x.x | :white_check_mark: | + +## Reporting a Vulnerability + +Individuals or organizations that are experiencing a product security issue are strongly encouraged to contact the [project maintainers](mailto:mrz1818@pm.me). +We welcome reports from independent researchers, industry organizations, vendors, customers, and other sources concerned with our project security. +The minimal data needed for reporting a security issue is a description of the potential vulnerability. \ No newline at end of file diff --git a/api.go b/api.go deleted file mode 100644 index 64d2008..0000000 --- a/api.go +++ /dev/null @@ -1,60 +0,0 @@ -package customerio - -import ( - "bytes" - "context" - "encoding/json" - "io/ioutil" - "net/http" -) - -type APIClient struct { - Key string - URL string - Client *http.Client -} - -// NewAPIClient prepares a client for use with the Customer.io API, see: https://customer.io/docs/api/#apicoreintroduction -// using an App API Key from https://fly.customer.io/settings/api_credentials?keyType=app -func NewAPIClient(key string, opts ...option) *APIClient { - client := &APIClient{ - Key: key, - Client: http.DefaultClient, - URL: "https://api.customer.io", - } - - for _, opt := range opts { - opt.api(client) - } - return client -} - -func (c *APIClient) doRequest(ctx context.Context, verb, requestPath string, body interface{}) ([]byte, int, error) { - b, err := json.Marshal(body) - if err != nil { - return nil, 0, err - } - - req, err := http.NewRequest(verb, c.URL+requestPath, bytes.NewBuffer(b)) - if err != nil { - return nil, 0, err - } - - req = req.WithContext(ctx) - - req.Header.Set("Authorization", "Bearer "+c.Key) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.Client.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - respBody, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, 0, err - } - - return respBody, resp.StatusCode, nil -} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..7861408 --- /dev/null +++ b/auth.go @@ -0,0 +1,51 @@ +package customerio + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// RegionInfo is the response from FindRegion +type RegionInfo struct { + DataCenter string `json:"data_center"` + EnvironmentID uint64 `json:"environment_id"` + URL string `json:"url"` +} + +/* +// Example Response +{ + "url": "https://track.customer.io", + "data_center": "us", + "environment_id": 3 +} +*/ + +// FindRegion will return the url, data center and environment id +// See: https://customer.io/docs/api/#tag/trackAuth +func (c *Client) FindRegion() (region *RegionInfo, err error) { + var resp StandardResponse + if resp, err = c.request( + http.MethodGet, + fmt.Sprintf("%s/api/v1/accounts/region", c.options.trackURL), + http.StatusOK, + nil, + ); err != nil { + return + } + err = json.Unmarshal(resp.Body, ®ion) + return +} + +// TestAuth will test your current Tracking API credentials +// See: https://customer.io/docs/api/#tag/trackAuth +func (c *Client) TestAuth() error { + _, err := c.request( + http.MethodGet, + fmt.Sprintf("%s/auth", c.options.trackURL), + http.StatusOK, + nil, + ) + return err +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..89f0ce4 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,91 @@ +package customerio + +import ( + "fmt" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +// TestClient_FindRegion will test the method FindRegion() +func TestClient_FindRegion(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockFindRegion(http.StatusOK) + + var regionInfo *RegionInfo + regionInfo, err = client.FindRegion() + assert.NoError(t, err) + assert.NotNil(t, regionInfo) + assert.Equal(t, "https://track.customer.io", regionInfo.URL) + assert.Equal(t, "us", regionInfo.DataCenter) + assert.Equal(t, uint64(3), regionInfo.EnvironmentID) + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockFindRegion(http.StatusUnprocessableEntity) + + var regionInfo *RegionInfo + regionInfo, err = client.FindRegion() + assert.Error(t, err) + assert.Nil(t, regionInfo) + }) +} + +// TestClient_TestAuth will test the method TestAuth() +func TestClient_TestAuth(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockTestAuth(http.StatusOK) + + err = client.TestAuth() + assert.NoError(t, err) + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockTestAuth(http.StatusUnauthorized) + + err = client.TestAuth() + assert.Error(t, err) + }) +} + +// mockFindRegion is used for mocking the response +func mockFindRegion(statusCode int) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodGet, fmt.Sprintf("%sapi/v1/accounts/region", testTrackingAPIURL), + httpmock.NewStringResponder( + statusCode, `{"url": "https://track.customer.io","data_center": "us","environment_id": 3}`, + ), + ) +} + +// mockTestAuth is used for mocking the response +func mockTestAuth(statusCode int) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodGet, fmt.Sprintf("%sauth", testTrackingAPIURL), + httpmock.NewStringResponder( + statusCode, `{"meta": {"message": "Nice credentials."}}`, + ), + ) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..f97d26e --- /dev/null +++ b/client.go @@ -0,0 +1,270 @@ +package customerio + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-resty/resty/v2" +) + +// Client is the CustomerIO client/configuration +type Client struct { + httpClient *resty.Client + options *clientOptions // Options are all the default settings / configuration +} + +// ClientOptions holds all the configuration for client requests and default resources +// See: https://fly.customer.io/settings/api_credentials +type clientOptions struct { + apiURL string // Regional API endpoint (URL) + appAPIKey string // App or Beta API key + httpTimeout time.Duration // Default timeout in seconds for GET requests + requestTracing bool // If enabled, it will trace the request timing + retryCount int // Default retry count for HTTP requests + siteID string // Used in conjunction with the Tracking API key + trackingAPIKey string // Tracking API key (Only tracking API requests) + trackURL string // Regional Tracking API endpoint (URL) + userAgent string // User agent for all outgoing requests +} + +// region is used for changing the location of the API endpoints +type region struct { + apiURL string + trackURL string +} + +// Current regions available +var ( + RegionUS = region{ + apiURL: "https://api.customer.io", + trackURL: "https://track.customer.io", + } + RegionEU = region{ + apiURL: "https://api-eu.customer.io", + trackURL: "https://track-eu.customer.io", + } +) + +// ClientOps allow functional options to be supplied +// that overwrite default client options. +type ClientOps func(c *clientOptions) + +// WithRegion will change the region API endpoints +func WithRegion(r region) ClientOps { + return func(c *clientOptions) { + c.apiURL = r.apiURL + c.trackURL = r.trackURL + } +} + +// WithHTTPTimeout can be supplied to adjust the default http client timeouts. +// The http client is used when creating requests +// Default timeout is 20 seconds. +func WithHTTPTimeout(timeout time.Duration) ClientOps { + return func(c *clientOptions) { + c.httpTimeout = timeout + } +} + +// WithRequestTracing will enable tracing. +// Tracing is disabled by default. +func WithRequestTracing() ClientOps { + return func(c *clientOptions) { + c.requestTracing = true + } +} + +// WithRetryCount will overwrite the default retry count for http requests. +// Default retries is 2. +func WithRetryCount(retries int) ClientOps { + return func(c *clientOptions) { + c.retryCount = retries + } +} + +// WithUserAgent will overwrite the default useragent. +// Default is package name + version. +func WithUserAgent(userAgent string) ClientOps { + return func(c *clientOptions) { + c.userAgent = userAgent + } +} + +// WithTrackingKey will provide the SiteID and Tracking API key +// See: https://fly.customer.io/settings/api_credentials +func WithTrackingKey(siteID, trackingAPIKey string) ClientOps { + return func(c *clientOptions) { + c.siteID = siteID + c.trackingAPIKey = trackingAPIKey + } +} + +// WithAppKey will provide the App or Beta API key +// See: https://fly.customer.io/settings/api_credentials?keyType=app +func WithAppKey(appAPIKey string) ClientOps { + return func(c *clientOptions) { + c.appAPIKey = appAPIKey + } +} + +// WithCustomHTTPClient will overwrite the default client with a custom client. +func (c *Client) WithCustomHTTPClient(client *resty.Client) *Client { + c.httpClient = client + return c +} + +// GetUserAgent will return the user agent string of the client +func (c *Client) GetUserAgent() string { + return c.options.userAgent +} + +// defaultClientOptions will return an Options struct with the default settings +// +// Useful for starting with the default and then modifying as needed +func defaultClientOptions() (opts *clientOptions, err error) { + // Set the default options + opts = &clientOptions{ + apiURL: RegionUS.apiURL, + httpTimeout: defaultHTTPTimeout, + requestTracing: false, + retryCount: defaultRetryCount, + trackURL: RegionUS.trackURL, + userAgent: defaultUserAgent, + } + return +} + +// NewClient creates a new client for all CustomerIO requests (tracking, app, beta) +// +// If no options are given, it will use the DefaultClientOptions() +// If no client is supplied it will use a default Resty HTTP client +func NewClient(opts ...ClientOps) (*Client, error) { + defaults, err := defaultClientOptions() + if err != nil { + return nil, err + } + // Create a new client + client := &Client{ + options: defaults, + } + // overwrite defaults with any set by user + for _, opt := range opts { + opt(client.options) + } + // Check for at least one type of API key + if client.options.trackingAPIKey == "" && client.options.appAPIKey == "" { + return nil, errors.New("missing an API Key (Tracking or App)") + } + // Set the Resty HTTP client + if client.httpClient == nil { + client.httpClient = resty.New() + // Set defaults (for GET requests) + client.httpClient.SetTimeout(client.options.httpTimeout) + client.httpClient.SetRetryCount(client.options.retryCount) + } + return client, nil +} + +// auth creates the Basic Auth string using the SiteID and API Key +func (c *Client) auth() string { + return base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", c.options.siteID, c.options.trackingAPIKey))) +} + +// request is a standard GET / POST / PUT / DELETE request for all outgoing HTTP requests +// Omit the data attribute if using a GET request +func (c *Client) request(httpMethod string, requestURL string, expectedStatusCode int, + data interface{}) (response StandardResponse, err error) { + + // Set the user agent + req := c.httpClient.R().SetHeader("User-Agent", c.options.userAgent) + + // Set the body if (PUT || POST) + if httpMethod != http.MethodGet && httpMethod != http.MethodDelete { + var j []byte + j, err = json.Marshal(data) + if err != nil { + return + } + req = req.SetBody(string(j)) + req.Header.Add("Content-Length", strconv.Itoa(len(j))) + req.Header.Set("Content-Type", "application/json") + } + + // Enable tracing + if c.options.requestTracing { + req.EnableTrace() + } + + // Set the authorization and content type + if strings.Contains(requestURL, c.options.trackURL) { + req.Header.Add("Authorization", "Basic "+c.auth()) + } else { // App or Beta + req.Header.Set("Authorization", "Bearer "+c.options.appAPIKey) + } + + // Fire the request + var resp *resty.Response + switch httpMethod { + case http.MethodPost: + if resp, err = req.Post(requestURL); err != nil { + return + } + case http.MethodPut: + if resp, err = req.Put(requestURL); err != nil { + return + } + case http.MethodDelete: + if resp, err = req.Delete(requestURL); err != nil { + return + } + case http.MethodGet: + if resp, err = req.Get(requestURL); err != nil { + return + } + } + + // Tracing enabled? + if c.options.requestTracing { + response.Tracing = resp.Request.TraceInfo() + } + + // Set the status code & body + response.StatusCode = resp.StatusCode() + response.Body = resp.Body() + + // Process if error (different error formats for different API endpoint/urls) + if expectedStatusCode != response.StatusCode { + if strings.Contains(requestURL, "/v1/send/email") { + var meta struct { + Meta struct { + Err string `json:"error"` + } `json:"meta"` + } + if err = json.Unmarshal(response.Body, &meta); err != nil { + err = &TransactionalError{ + StatusCode: response.StatusCode, + Err: string(response.Body), + } + } else { + err = &TransactionalError{ + StatusCode: response.StatusCode, + Err: meta.Meta.Err, + } + } + return + } else { + err = &APIError{ + status: response.StatusCode, + url: requestURL, + body: response.Body, + } + } + } + return +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..d39bf1b --- /dev/null +++ b/client_test.go @@ -0,0 +1,168 @@ +package customerio + +import ( + "fmt" + "testing" + "time" + + "github.com/go-resty/resty/v2" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +const ( + testSiteID = "TestSiteID1234567" + testAppAPIKey = "TestAPIKey1234567" + testTrackingAPIKey = "TestTrackingAPIKey1234567" +) + +// newTestClient will return a client for testing purposes +func newTestClient() (*Client, error) { + // Create a Resty Client + client := resty.New() + + // Get the underlying HTTP Client and set it to Mock + httpmock.ActivateNonDefault(client.GetClient()) + + // Create a new client + newClient, err := NewClient(WithRequestTracing(), WithTrackingKey(testSiteID, testTrackingAPIKey)) + if err != nil { + return nil, err + } + newClient.WithCustomHTTPClient(client) + + // Return the mocking client + return newClient, nil +} + +// TestNewClient will test the method NewClient() +func TestNewClient(t *testing.T) { + t.Parallel() + + t.Run("default client", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey)) + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, defaultUserAgent, client.options.userAgent) + assert.Equal(t, defaultHTTPTimeout, client.options.httpTimeout) + assert.Equal(t, defaultRetryCount, client.options.retryCount) + assert.Equal(t, false, client.options.requestTracing) + assert.Equal(t, RegionUS.apiURL, client.options.apiURL) + assert.Equal(t, RegionUS.trackURL, client.options.trackURL) + }) + + t.Run("custom http client", func(t *testing.T) { + customHTTPClient := resty.New() + customHTTPClient.SetTimeout(defaultHTTPTimeout) + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey)) + assert.NoError(t, err) + assert.NotNil(t, client) + client.WithCustomHTTPClient(customHTTPClient) + }) + + t.Run("custom http timeout", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithHTTPTimeout(10*time.Second)) + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, 10*time.Second, client.options.httpTimeout) + }) + + t.Run("custom retry count", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithRetryCount(3)) + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, 3, client.options.retryCount) + }) + + t.Run("custom options", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithUserAgent("custom user agent")) + assert.NotNil(t, client) + assert.NoError(t, err) + }) + + t.Run("custom region (EU)", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithRegion(RegionEU)) + assert.NotNil(t, client) + assert.NoError(t, err) + assert.Equal(t, client.options.apiURL, "https://api-eu.customer.io") + assert.Equal(t, client.options.trackURL, "https://track-eu.customer.io") + }) + + t.Run("custom region (US)", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithRegion(RegionUS)) + assert.NotNil(t, client) + assert.NoError(t, err) + assert.Equal(t, client.options.apiURL, "https://api.customer.io") + assert.Equal(t, client.options.trackURL, "https://track.customer.io") + }) + + t.Run("test auth (tracking)", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithRegion(RegionUS)) + assert.NotNil(t, client) + assert.NoError(t, err) + assert.Equal(t, "VGVzdFNpdGVJRDEyMzQ1Njc6VGVzdFRyYWNraW5nQVBJS2V5MTIzNDU2Nw==", client.auth()) + assert.Equal(t, testSiteID, client.options.siteID) + assert.Equal(t, testTrackingAPIKey, client.options.trackingAPIKey) + }) + + t.Run("test app api key", func(t *testing.T) { + client, err := NewClient(WithAppKey(testAppAPIKey)) + assert.NotNil(t, client) + assert.NoError(t, err) + assert.Equal(t, testAppAPIKey, client.options.appAPIKey) + }) +} + +// TestClient_GetUserAgent will test the method GetUserAgent() +func TestClient_GetUserAgent(t *testing.T) { + t.Parallel() + + t.Run("get user agent", func(t *testing.T) { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey)) + assert.NoError(t, err) + assert.NotNil(t, client) + userAgent := client.GetUserAgent() + assert.Equal(t, defaultUserAgent, userAgent) + }) +} + +// ExampleNewClient example using NewClient() +// +// See more examples in /examples/ +func ExampleNewClient() { + client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey)) + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + fmt.Printf("loaded client: %s", client.options.userAgent) + // Output:loaded client: go-customerio: v1.2.0 +} + +// BenchmarkNewClient benchmarks the method NewClient() +func BenchmarkNewClient(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey)) + } +} + +// TestDefaultClientOptions will test the method defaultClientOptions() +func TestDefaultClientOptions(t *testing.T) { + t.Parallel() + + options, err := defaultClientOptions() + assert.NoError(t, err) + assert.NotNil(t, options) + + assert.Equal(t, defaultUserAgent, options.userAgent) + assert.Equal(t, defaultHTTPTimeout, options.httpTimeout) + assert.Equal(t, defaultRetryCount, options.retryCount) + assert.Equal(t, false, options.requestTracing) +} + +// BenchmarkDefaultClientOptions benchmarks the method defaultClientOptions() +func BenchmarkDefaultClientOptions(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = defaultClientOptions() + } +} diff --git a/customerio.go b/customerio.go index cfe10ad..cf4068f 100644 --- a/customerio.go +++ b/customerio.go @@ -1,205 +1,9 @@ +// Package customerio is Golang library for implementing the CustomerIO API +// following their documentation: https://customer.io/docs/api/ +// +// If you have any suggestions or comments, please feel free to open an issue on +// this GitHub repository! +// +// By CustomerIO (Original repo) +// By MrZ (Fork of Original repo) package customerio - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strconv" -) - -// CustomerIO wraps the customer.io track API, see: https://customer.io/docs/api/#apitrackintroduction -type CustomerIO struct { - siteID string - apiKey string - URL string - Client *http.Client -} - -// CustomerIOError is returned by any method that fails at the API level -type CustomerIOError struct { - status int - url string - body []byte -} - -func (e *CustomerIOError) Error() string { - return fmt.Sprintf("%v: %v %v", e.status, e.url, string(e.body)) -} - -// ParamError is an error returned if a parameter to the track API is invalid. -type ParamError struct { - Param string // Param is the name of the parameter. -} - -func (e ParamError) Error() string { return e.Param + ": missing" } - -// NewTrackClient prepares a client for use with the Customer.io track API, see: https://customer.io/docs/api/#apitrackintroduction -// using a Tracking Site ID and API Key pair from https://fly.customer.io/settings/api_credentials -func NewTrackClient(siteID, apiKey string, opts ...option) *CustomerIO { - client := &http.Client{ - Transport: &http.Transport{ - MaxIdleConnsPerHost: 100, - }, - } - c := &CustomerIO{ - siteID: siteID, - apiKey: apiKey, - URL: "https://track.customer.io", - Client: client, - } - - for _, opt := range opts { - opt.track(c) - } - - return c -} - -// NewCustomerIO prepares a client for use with the Customer.io track API, see: https://customer.io/docs/api/#apitrackintroduction -// deprecated in favour of NewTrackClient -func NewCustomerIO(siteID, apiKey string) *CustomerIO { - return NewTrackClient(siteID, apiKey) -} - -// Identify identifies a customer and sets their attributes -func (c *CustomerIO) Identify(customerID string, attributes map[string]interface{}) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - return c.request("PUT", - fmt.Sprintf("%s/api/v1/customers/%s", c.URL, url.PathEscape(customerID)), - attributes) -} - -// Track sends a single event to Customer.io for the supplied user -func (c *CustomerIO) Track(customerID string, eventName string, data map[string]interface{}) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - if eventName == "" { - return ParamError{Param: "eventName"} - } - return c.request("POST", - fmt.Sprintf("%s/api/v1/customers/%s/events", c.URL, url.PathEscape(customerID)), - map[string]interface{}{ - "name": eventName, - "data": data, - }) -} - -// TrackAnonymous sends a single event to Customer.io for the anonymous user -func (c *CustomerIO) TrackAnonymous(eventName string, data map[string]interface{}) error { - if eventName == "" { - return ParamError{Param: "eventName"} - } - return c.request("POST", - fmt.Sprintf("%s/api/v1/events", c.URL), - map[string]interface{}{ - "name": eventName, - "data": data, - }) -} - -// Delete deletes a customer -func (c *CustomerIO) Delete(customerID string) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - return c.request("DELETE", - fmt.Sprintf("%s/api/v1/customers/%s", c.URL, url.PathEscape(customerID)), - nil) -} - -// AddDevice adds a device for a customer -func (c *CustomerIO) AddDevice(customerID string, deviceID string, platform string, data map[string]interface{}) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - if deviceID == "" { - return ParamError{Param: "deviceID"} - } - if platform == "" { - return ParamError{Param: "platform"} - } - - body := map[string]map[string]interface{}{ - "device": { - "id": deviceID, - "platform": platform, - }, - } - for k, v := range data { - body["device"][k] = v - } - return c.request("PUT", - fmt.Sprintf("%s/api/v1/customers/%s/devices", c.URL, url.PathEscape(customerID)), - body) -} - -// DeleteDevice deletes a device for a customer -func (c *CustomerIO) DeleteDevice(customerID string, deviceID string) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - if deviceID == "" { - return ParamError{Param: "deviceID"} - } - return c.request("DELETE", - fmt.Sprintf("%s/api/v1/customers/%s/devices/%s", c.URL, url.PathEscape(customerID), url.PathEscape(deviceID)), - nil) -} - -func (c *CustomerIO) auth() string { - return base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", c.siteID, c.apiKey))) -} - -func (c *CustomerIO) request(method, url string, body interface{}) error { - var req *http.Request - if body != nil { - j, err := json.Marshal(body) - if err != nil { - return err - } - - req, err = http.NewRequest(method, url, bytes.NewBuffer(j)) - if err != nil { - return err - } - - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Content-Length", strconv.Itoa(len(j))) - } else { - var err error - req, err = http.NewRequest(method, url, nil) - if err != nil { - return err - } - } - - req.Header.Add("Authorization", fmt.Sprintf("Basic %v", c.auth())) - - resp, err := c.Client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - responseBody, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - - if resp.StatusCode != http.StatusOK { - return &CustomerIOError{ - status: resp.StatusCode, - url: url, - body: responseBody, - } - } - - return nil -} diff --git a/customerio_test.go b/customerio_test.go index 759a233..c8dc2dc 100644 --- a/customerio_test.go +++ b/customerio_test.go @@ -1,271 +1,10 @@ -package customerio_test - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "os" - "strconv" - "strings" - "testing" - - "github.com/customerio/go-customerio" -) - -var cio *customerio.CustomerIO - -func TestMain(m *testing.M) { - srv := httptest.NewServer(http.HandlerFunc(handler)) - defer srv.Close() - - cio = customerio.NewCustomerIO("siteid", "apikey") - cio.URL = srv.URL - - os.Exit(m.Run()) -} - -type testCase struct { - id string - method string - path string - body interface{} -} - -func runCases(t *testing.T, cases []testCase, do func(c testCase) error) { - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - expect(c.method, c.path, c.body) - if err := do(c); err != nil { - t.Error(err.Error()) - } - }) - } -} -func checkParamError(t *testing.T, err error, param string) { - if err == nil { - t.Error("expected error") - return - } - pe, ok := err.(customerio.ParamError) - if !ok { - t.Error("expected ParamError") - } - if pe.Param != param { - t.Errorf("expected %s got %s", param, pe.Param) - } -} - -func TestIdentify(t *testing.T) { - attributes := map[string]interface{}{ - "a": "1", - } - err := cio.Identify("", attributes) - checkParamError(t, err, "customerID") - - runCases(t, - []testCase{ - {"1", "PUT", "/api/v1/customers/1", attributes}, - {"1 ", "PUT", "/api/v1/customers/1%20", attributes}, - {"1/", "PUT", "/api/v1/customers/1%2F", attributes}, - }, - func(c testCase) error { - return cio.Identify(c.id, attributes) - }) -} - -func TestTrack(t *testing.T) { - data := map[string]interface{}{ - "a": "1", - } - - body := map[string]interface{}{ - "name": "test", - "data": map[string]interface{}{ - "a": "1", - }, - } - err := cio.Track("", "test", data) - checkParamError(t, err, "customerID") - err = cio.Track("1", "", data) - checkParamError(t, err, "eventName") - - runCases(t, - []testCase{ - {"1", "POST", "/api/v1/customers/1/events", body}, - {"1 ", "POST", "/api/v1/customers/1%20/events", body}, - {"1/", "POST", "/api/v1/customers/1%2F/events", body}, - }, - func(c testCase) error { - return cio.Track(c.id, "test", data) - }) -} - -func TestTrackAnonymous(t *testing.T) { - data := map[string]interface{}{ - "a": "1", - } - - body := map[string]interface{}{ - "name": "test", - "data": map[string]interface{}{ - "a": "1", - }, - } - - expect("POST", "/api/v1/events", body) - if err := cio.TrackAnonymous("test", data); err != nil { - t.Error(err.Error()) - } -} - -func TestDelete(t *testing.T) { - err := cio.Delete("") - checkParamError(t, err, "customerID") - runCases(t, - []testCase{ - {"1", "DELETE", "/api/v1/customers/1", nil}, - {"1 ", "DELETE", "/api/v1/customers/1%20", nil}, - {"1/", "DELETE", "/api/v1/customers/1%2F", nil}, - }, - func(c testCase) error { - return cio.Delete(c.id) - }) -} - -func TestAddDevice(t *testing.T) { - err := cio.AddDevice("", "d1", "ios", nil) - checkParamError(t, err, "customerID") - err = cio.AddDevice("1", "", "ios", nil) - checkParamError(t, err, "deviceID") - err = cio.AddDevice("1", "d1", "", nil) - checkParamError(t, err, "platform") - - body := map[string]map[string]interface{}{ - "device": { - "id": "d1", - "platform": "ios", - "last_used": 1606511962, - }, - } - runCases(t, - []testCase{ - {"1", "PUT", "/api/v1/customers/1/devices", body}, - {"1 ", "PUT", "/api/v1/customers/1%20/devices", body}, - {"1/", "PUT", "/api/v1/customers/1%2F/devices", body}, - }, - func(c testCase) error { - return cio.AddDevice(c.id, "d1", "ios", map[string]interface{}{ - "last_used": 1606511962, - }) - }) -} - -func TestDeleteDevice(t *testing.T) { - err := cio.DeleteDevice("", "d1") - checkParamError(t, err, "customerID") - - err = cio.DeleteDevice("1", "") - checkParamError(t, err, "deviceID") - - runCases(t, - []testCase{ - {"1", "DELETE", "/api/v1/customers/1/devices/d1", nil}, - {"1 ", "DELETE", "/api/v1/customers/1%20/devices/d1", nil}, - {"1/", "DELETE", "/api/v1/customers/1%2F/devices/d1", nil}, - {"2", "DELETE", "/api/v1/customers/d1/devices/2", nil}, - {"2 ", "DELETE", "/api/v1/customers/d1/devices/2%20", nil}, - {"2/", "DELETE", "/api/v1/customers/d1/devices/2%2F", nil}, - }, - func(c testCase) error { - if c.id[0] == '2' { - return cio.DeleteDevice("d1", c.id) - } else { - return cio.DeleteDevice(c.id, "d1") - } - }) -} - -var ( - expectedMethod string - expectedPath string - expectedBody interface{} +package customerio + +const ( + testAppAPIURL = "https://api.customer.io/" + testCustomerEmail = "bob@example.com" + testCustomerID = "123" + testDeviceID = "abcdefghijklmnopqrstuvwxyz" + testEventName = "test_event" + testTrackingAPIURL = "https://track.customer.io/" ) - -func handler(w http.ResponseWriter, req *http.Request) { - b, err := ioutil.ReadAll(req.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer req.Body.Close() - - s := strings.SplitN(req.Header.Get("Authorization"), " ", 2) - if len(s) != 2 || s[0] != "Basic" { - w.WriteHeader(http.StatusUnauthorized) - return - } - - decoded, err := base64.StdEncoding.DecodeString(s[1]) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - pair := strings.SplitN(string(decoded), ":", 2) - if len(pair) != 2 { - w.WriteHeader(http.StatusUnauthorized) - return - } - if pair[0] != "siteid" && pair[1] != "apikey" { - w.WriteHeader(http.StatusUnauthorized) - return - } - - if req.Method != "DELETE" && req.Header.Get("Content-Type") != "application/json" { - http.Error(w, "expected Content-Type application/json", http.StatusBadRequest) - } - - var data map[string]interface{} - if len(b) > 0 { - dec := json.NewDecoder(bytes.NewReader(b)) - dec.UseNumber() - if err := dec.Decode(&data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - validate := func(method, path string, body interface{}) error { - if method != expectedMethod { - return fmt.Errorf("expected %s got %s", expectedMethod, method) - } - if path != expectedPath { - return fmt.Errorf("expected %s got %s", expectedPath, path) - } - expected, err := json.Marshal(body) - if err != nil { - return err - } - got, err := json.Marshal(data) - if err != nil { - return err - } - if bytes.Compare(expected, got) != 0 { - return fmt.Errorf("expected %v got %v", expected, got) - } - return nil - } - if err := validate(req.Method, req.RequestURI, data); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusOK) -} - -func expect(method, path string, body interface{}) { - expectedMethod = method - expectedPath = path - expectedBody = body -} diff --git a/customers.go b/customers.go new file mode 100644 index 0000000..7d5dfd3 --- /dev/null +++ b/customers.go @@ -0,0 +1,92 @@ +package customerio + +import ( + "fmt" + "net/http" + "net/url" +) + +// UpdateCustomer will add/update a customer and sets their attributes +// If not found, a customer will be created. If found, the attributes will be updated +// See: https://customer.io/docs/api/#operation/identify +// AKA: Identify() +// Only use "email" if the workspace is setup to use email instead of ID +func (c *Client) UpdateCustomer(customerIDOrEmail string, attributes map[string]interface{}) error { + if customerIDOrEmail == "" { + return ParamError{Param: "customerIDOrEmail"} + } + _, err := c.request( + http.MethodPut, + fmt.Sprintf("%s/api/v1/customers/%s", c.options.trackURL, url.PathEscape(customerIDOrEmail)), + http.StatusOK, + attributes, + ) + return err +} + +// DeleteCustomer will remove a customer given their id or email +// If not found, a customer will be created. If found, the attributes will be updated +// See: https://customer.io/docs/api/#operation/delete +// Only use "email" if the workspace is setup to use email instead of ID +func (c *Client) DeleteCustomer(customerIDOrEmail string) error { + if customerIDOrEmail == "" { + return ParamError{Param: "customerIDOrEmail"} + } + _, err := c.request( + http.MethodDelete, + fmt.Sprintf("%s/api/v1/customers/%s", c.options.trackURL, url.PathEscape(customerIDOrEmail)), + http.StatusOK, + nil, + ) + return err +} + +// UpdateDevice will add/update a customer's device +// If not found, a device will be created. If found, the attributes will be updated +// See: https://customer.io/docs/api/#operation/add_device +// Only use "email" if the workspace is setup to use email instead of ID +func (c *Client) UpdateDevice(customerIDOrEmail string, device *Device) error { + if customerIDOrEmail == "" { + return ParamError{Param: "customerIDOrEmail"} + } + if device == nil { + return ParamError{Param: "device"} + } else if device.ID == "" { + return ParamError{Param: "deviceID"} + } else if !acceptedPlatforms(device.Platform) { + return ParamError{Param: "devicePlatform"} + } + _, err := c.request( + http.MethodPut, + fmt.Sprintf("%s/api/v1/customers/%s/devices", c.options.trackURL, url.PathEscape(customerIDOrEmail)), + http.StatusOK, + map[string]interface{}{ + "device": device, + }, + ) + return err +} + +// DeleteDevice will remove a customer's device +// See: https://customer.io/docs/api/#operation/delete_device +// Only use "email" if the workspace is setup to use email instead of ID +func (c *Client) DeleteDevice(customerIDOrEmail, deviceID string) error { + if customerIDOrEmail == "" { + return ParamError{Param: "customerIDOrEmail"} + } + if deviceID == "" { + return ParamError{Param: "deviceID"} + } + _, err := c.request( + http.MethodDelete, + fmt.Sprintf( + "%s/api/v1/customers/%s/devices/%s", + c.options.trackURL, + url.PathEscape(customerIDOrEmail), + url.PathEscape(deviceID), + ), + http.StatusOK, + nil, + ) + return err +} diff --git a/customers_test.go b/customers_test.go new file mode 100644 index 0000000..6f87313 --- /dev/null +++ b/customers_test.go @@ -0,0 +1,488 @@ +package customerio + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +// TestClient_UpdateCustomer will test the method UpdateCustomer() +func TestClient_UpdateCustomer(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response (ID)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCustomer(http.StatusOK, testCustomerID) + + err = client.UpdateCustomer(testCustomerID, map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": testCustomerEmail, + "first_name": "Bob", + "plan": "basic", + }) + assert.NoError(t, err) + }) + + t.Run("missing customer id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCustomer(http.StatusOK, testCustomerID) + + err = client.UpdateCustomer("", map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": testCustomerEmail, + "first_name": "Bob", + "plan": "basic", + }) + assert.Error(t, err) + checkParamError(t, err, "customerIDOrEmail") + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCustomer(http.StatusUnprocessableEntity, testCustomerID) + + err = client.UpdateCustomer(testCustomerID, map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": testCustomerEmail, + "first_name": "Bob", + "plan": "basic", + }) + assert.Error(t, err) + }) + + t.Run("successful response (Email)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCustomerUsingEmail(http.StatusOK, testCustomerEmail) + + err = client.UpdateCustomer(testCustomerEmail, map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": testCustomerEmail, + "first_name": "Bob", + "plan": "basic", + }) + assert.NoError(t, err) + }) +} + +// ExampleClient_UpdateCustomer example using UpdateCustomer() +// +// See more examples in /examples/ +func ExampleClient_UpdateCustomer() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockUpdateCustomer(http.StatusOK, testCustomerID) + + // Update customer + err = client.UpdateCustomer(testCustomerID, map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": testCustomerEmail, + "first_name": "Bob", + "plan": "basic", + }) + if err != nil { + fmt.Printf("error updating customer: " + err.Error()) + return + } + fmt.Printf("customer updated: %s", testCustomerID) + // Output:customer updated: 123 +} + +// BenchmarkClient_UpdateCustomer benchmarks the method UpdateCustomer() +func BenchmarkClient_UpdateCustomer(b *testing.B) { + client, _ := newTestClient() + mockUpdateCustomer(http.StatusOK, testCustomerID) + attributes := map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": testCustomerEmail, + "first_name": "Bob", + "plan": "basic", + } + for i := 0; i < b.N; i++ { + _ = client.UpdateCustomer(testCustomerID, attributes) + } +} + +// TestClient_DeleteCustomer will test the method DeleteCustomer() +func TestClient_DeleteCustomer(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response (ID)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteCustomer(http.StatusOK, testCustomerID) + + err = client.DeleteCustomer(testCustomerID) + assert.NoError(t, err) + }) + + t.Run("missing customer id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteCustomer(http.StatusOK, testCustomerID) + + err = client.DeleteCustomer("") + assert.Error(t, err) + checkParamError(t, err, "customerIDOrEmail") + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteCustomer(http.StatusUnprocessableEntity, testCustomerID) + + err = client.DeleteCustomer(testCustomerID) + assert.Error(t, err) + }) +} + +// ExampleClient_DeleteCustomer example using DeleteCustomer() +// +// See more examples in /examples/ +func ExampleClient_DeleteCustomer() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockDeleteCustomer(http.StatusOK, testCustomerID) + + // Delete customer + err = client.DeleteCustomer(testCustomerID) + if err != nil { + fmt.Printf("error deleting customer: " + err.Error()) + return + } + fmt.Printf("customer deleted: %s", testCustomerID) + // Output:customer deleted: 123 +} + +// BenchmarkClient_DeleteCustomer benchmarks the method DeleteCustomer() +func BenchmarkClient_DeleteCustomer(b *testing.B) { + client, _ := newTestClient() + mockDeleteCustomer(http.StatusOK, testCustomerID) + for i := 0; i < b.N; i++ { + _ = client.DeleteCustomer(testCustomerID) + } +} + +// TestClient_UpdateDevice will test the method UpdateDevice() +func TestClient_UpdateDevice(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response (ID)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateDevice(http.StatusOK, testCustomerID) + + err = client.UpdateDevice(testCustomerID, &Device{ + ID: testDeviceID, + LastUsed: time.Now().UTC().Unix(), + Platform: PlatformIOs, + }) + assert.NoError(t, err) + }) + + t.Run("missing customer id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateDevice(http.StatusOK, testCustomerID) + + err = client.UpdateDevice("", &Device{ + ID: testDeviceID, + LastUsed: time.Now().UTC().Unix(), + Platform: PlatformIOs, + }) + assert.Error(t, err) + checkParamError(t, err, "customerIDOrEmail") + }) + + t.Run("missing device", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateDevice(http.StatusOK, testCustomerID) + + err = client.UpdateDevice(testCustomerID, nil) + assert.Error(t, err) + checkParamError(t, err, "device") + }) + + t.Run("missing device id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateDevice(http.StatusOK, testCustomerID) + + err = client.UpdateDevice(testCustomerID, &Device{ + ID: "", + LastUsed: time.Now().UTC().Unix(), + Platform: PlatformIOs, + }) + assert.Error(t, err) + checkParamError(t, err, "deviceID") + }) + + t.Run("invalid platform", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateDevice(http.StatusOK, testCustomerID) + + err = client.UpdateDevice(testCustomerID, &Device{ + ID: testDeviceID, + LastUsed: time.Now().UTC().Unix(), + Platform: "123", + }) + assert.Error(t, err) + checkParamError(t, err, "devicePlatform") + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateDevice(http.StatusUnprocessableEntity, testCustomerID) + + err = client.UpdateDevice(testCustomerID, &Device{ + ID: testDeviceID, + LastUsed: time.Now().UTC().Unix(), + Platform: PlatformIOs, + }) + assert.Error(t, err) + }) +} + +// ExampleClient_UpdateDevice example using UpdateDevice() +// +// See more examples in /examples/ +func ExampleClient_UpdateDevice() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockUpdateDevice(http.StatusOK, testCustomerID) + + // Delete customer + err = client.UpdateDevice(testCustomerID, &Device{ + ID: testDeviceID, + LastUsed: time.Now().UTC().Unix(), + Platform: PlatformIOs, + }) + if err != nil { + fmt.Printf("error updating device: " + err.Error()) + return + } + fmt.Printf("device updated: %s", testDeviceID) + // Output:device updated: abcdefghijklmnopqrstuvwxyz +} + +// BenchmarkClient_UpdateDevice benchmarks the method UpdateDevice() +func BenchmarkClient_UpdateDevice(b *testing.B) { + client, _ := newTestClient() + mockUpdateDevice(http.StatusOK, testCustomerID) + timestamp := time.Now().UTC().Unix() + device := &Device{ + ID: testDeviceID, + LastUsed: timestamp, + Platform: PlatformIOs, + } + for i := 0; i < b.N; i++ { + _ = client.UpdateDevice(testCustomerID, device) + } +} + +// TestClient_DeleteDevice will test the method DeleteDevice() +func TestClient_DeleteDevice(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response (ID)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteDevice(http.StatusOK, testCustomerID, testDeviceID) + + err = client.DeleteDevice(testCustomerID, testDeviceID) + assert.NoError(t, err) + }) + + t.Run("missing customer id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteDevice(http.StatusOK, testCustomerID, testDeviceID) + + err = client.DeleteDevice("", testDeviceID) + assert.Error(t, err) + checkParamError(t, err, "customerIDOrEmail") + }) + + t.Run("missing device id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteDevice(http.StatusOK, testCustomerID, testDeviceID) + + err = client.DeleteDevice(testCustomerID, "") + assert.Error(t, err) + checkParamError(t, err, "deviceID") + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockDeleteDevice(http.StatusUnprocessableEntity, testCustomerID, testDeviceID) + + err = client.DeleteDevice(testCustomerID, testDeviceID) + assert.Error(t, err) + }) +} + +// ExampleClient_DeleteDevice example using DeleteDevice() +// +// See more examples in /examples/ +func ExampleClient_DeleteDevice() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockDeleteDevice(http.StatusOK, testCustomerID, testDeviceID) + + // Delete device + err = client.DeleteDevice(testCustomerID, testDeviceID) + if err != nil { + fmt.Printf("error deleting device: " + err.Error()) + return + } + fmt.Printf("device deleted: %s", testDeviceID) + // Output:device deleted: abcdefghijklmnopqrstuvwxyz +} + +// BenchmarkClient_DeleteDevice benchmarks the method DeleteDevice() +func BenchmarkClient_DeleteDevice(b *testing.B) { + client, _ := newTestClient() + mockDeleteDevice(http.StatusOK, testCustomerID, testDeviceID) + for i := 0; i < b.N; i++ { + _ = client.DeleteDevice(testCustomerID, testDeviceID) + } +} + +// mockUpdateCustomer is used for mocking the response +func mockUpdateCustomer(statusCode int, customerID string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPut, fmt.Sprintf("%sapi/v1/customers/%s", testTrackingAPIURL, customerID), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockUpdateCustomerUsingEmail is used for mocking the response +func mockUpdateCustomerUsingEmail(statusCode int, customerEmail string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPut, fmt.Sprintf("%sapi/v1/customers/%s", testTrackingAPIURL, customerEmail), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockDeleteCustomer is used for mocking the response +func mockDeleteCustomer(statusCode int, customerID string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodDelete, fmt.Sprintf("%sapi/v1/customers/%s", testTrackingAPIURL, customerID), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockUpdateDevice is used for mocking the response +func mockUpdateDevice(statusCode int, customerID string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPut, fmt.Sprintf("%sapi/v1/customers/%s/devices", testTrackingAPIURL, customerID), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockDeleteDevice is used for mocking the response +func mockDeleteDevice(statusCode int, customerID, deviceID string) { + httpmock.Reset() + httpmock.RegisterResponder( + http.MethodDelete, + fmt.Sprintf( + "%sapi/v1/customers/%s/devices/%s", testTrackingAPIURL, customerID, deviceID, + ), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// checkParamError will ensure the error is a Param error with the correct field +func checkParamError(t *testing.T, err error, param string) { + if err == nil { + t.Error("expected error") + return + } + pe, ok := err.(ParamError) + if !ok { + t.Error("expected ParamError") + } + if pe.Param != param { + t.Errorf("expected %s got %s", param, pe.Param) + } +} diff --git a/definitions.go b/definitions.go new file mode 100644 index 0000000..b03d34c --- /dev/null +++ b/definitions.go @@ -0,0 +1,46 @@ +package customerio + +import ( + "time" + + "github.com/go-resty/resty/v2" +) + +// Defaults for all functions +const ( + defaultHTTPTimeout = 20 * time.Second // Default timeout for all GET requests in seconds + defaultRetryCount = 2 // Default retry count for HTTP requests + defaultUserAgent = "go-customerio: " + version // Default user agent + version = "v1.2.0" // CustomerIO version +) + +// DevicePlatform is the platform for the customer device +type DevicePlatform string + +// Allowed types of platforms +const ( + PlatformIOs DevicePlatform = "ios" + PlatformAndroid DevicePlatform = "android" +) + +// acceptedPlatforms will return true if the platform is accepted +func acceptedPlatforms(platform DevicePlatform) bool { + if platform == PlatformIOs || platform == PlatformAndroid { + return true + } + return false +} + +// Device is the customer device model +type Device struct { + ID string `json:"id"` + LastUsed int64 `json:"last_used"` + Platform DevicePlatform `json:"platform"` +} + +// StandardResponse is the standard fields returned on all responses +type StandardResponse struct { + Body []byte `json:"-"` // Body of the response request + StatusCode int `json:"-"` // Status code returned on the request + Tracing resty.TraceInfo `json:"-"` // Trace information if enabled on the request +} diff --git a/emails.go b/emails.go new file mode 100644 index 0000000..46d7cf9 --- /dev/null +++ b/emails.go @@ -0,0 +1,143 @@ +package customerio + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// EmailRequest is the request structure for sending an email +type EmailRequest struct { + AMPBody string `json:"amp_body,omitempty"` + Attachments map[string]string `json:"attachments,omitempty"` + BCC string `json:"bcc,omitempty"` + Body string `json:"body,omitempty"` + DisableMessageRetention *bool `json:"disable_message_retention,omitempty"` + EnableTracking *bool `json:"tracked,omitempty"` + FakeBCC *bool `json:"fake_bcc,omitempty"` + From string `json:"from,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Identifiers map[string]string `json:"identifiers"` + MessageData map[string]interface{} `json:"message_data,omitempty"` + PlaintextBody string `json:"plaintext_body,omitempty"` + Preheader string `json:"preheader,omitempty"` + QueueDraft *bool `json:"queue_draft,omitempty"` + ReplyTo string `json:"reply_to,omitempty"` + SendToUnsubscribed *bool `json:"send_to_unsubscribed,omitempty"` + Subject string `json:"subject,omitempty"` + To string `json:"to,omitempty"` + TransactionalMessageID string `json:"transactional_message_id,omitempty"` +} + +// ErrAttachmentExists is the error message if the attachment already exists +var ErrAttachmentExists = errors.New("attachment with this name already exists") + +// Attach will add a new file to the email +func (e *EmailRequest) Attach(name string, value io.Reader) error { + if e.Attachments == nil { + e.Attachments = map[string]string{} + } + if _, ok := e.Attachments[name]; ok { + return ErrAttachmentExists + } + + var buf bytes.Buffer + enc := base64.NewEncoder(base64.StdEncoding, &buf) + if _, err := io.Copy(enc, value); err != nil { + return err + } + + e.Attachments[name] = string(buf.Bytes()) + return nil +} + +// EmailResponse is the response from sending the email +type EmailResponse struct { + TransactionalResponse +} + +// TransactionalResponse is a response to the send of a transactional message. +type TransactionalResponse struct { + DeliveryID string `json:"delivery_id"` // DeliveryID is a unique id for the given message. + QueuedAt time.Time `json:"queued_at"` // QueuedAt is when the message was queued. +} + +// UnmarshalJSON will unmarshall the response +func (t *TransactionalResponse) UnmarshalJSON(b []byte) (err error) { + var r struct { + DeliveryID string `json:"delivery_id"` + QueuedAt int64 `json:"queued_at"` + } + if err = json.Unmarshal(b, &r); err != nil { + return + } + t.DeliveryID = r.DeliveryID + t.QueuedAt = time.Unix(r.QueuedAt, 0) + return +} + +// TransactionalError is returned if a transactional message fails to send. +type TransactionalError struct { + Err string // Err is a more specific error message. + StatusCode int // StatusCode is the http status code for the error. +} + +// Error with display the string error message +func (e *TransactionalError) Error() string { + return e.Err +} + +// SendEmail sends a single transactional email using the Customer.io transactional API +// See: https://customer.io/docs/api/#tag/Transactional +func (c *Client) SendEmail(emailRequest *EmailRequest) (*EmailResponse, error) { + + // Request cannot be nil, don't panic dude! + if emailRequest == nil { + return nil, ParamError{Param: "emailRequest"} + } + + // If a template is set (advanced error checking) + if len(emailRequest.TransactionalMessageID) > 0 { + if emailRequest.To == "" { + return nil, ParamError{Param: "emailTo"} + } else if len(emailRequest.Identifiers) == 0 { + return nil, ParamError{Param: "emailIdentifiers"} + } + } else { // NOT using a template + if emailRequest.Body == "" { + return nil, ParamError{Param: "emailBody"} + } else if emailRequest.Subject == "" { + return nil, ParamError{Param: "emailSubject"} + } else if emailRequest.To == "" { + return nil, ParamError{Param: "emailTo"} + } else if emailRequest.From == "" { + return nil, ParamError{Param: "emailFrom"} + } else if len(emailRequest.Identifiers) == 0 { + return nil, ParamError{Param: "emailIdentifiers"} + } + } + + // Attempt to send the email + response, err := c.request( + http.MethodPost, + fmt.Sprintf("%s/v1/send/email", c.options.apiURL), + http.StatusOK, + emailRequest, + ) + if err != nil { + return nil, err + } + + // Unmarshal the response + var r EmailResponse + if err = json.Unmarshal(response.Body, &r); err != nil { + return nil, err + } + + return &r, nil +} diff --git a/emails_test.go b/emails_test.go new file mode 100644 index 0000000..17f0f25 --- /dev/null +++ b/emails_test.go @@ -0,0 +1,284 @@ +package customerio + +import ( + "fmt" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +// TestClient_SendEmail will test the method SendEmail() +func TestClient_SendEmail(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + // Start the email request (no template) + emailRequestNoTemplate := &EmailRequest{ + Body: "This is an example body!", + From: "noreply@example.com", + Identifiers: map[string]string{"id": "123"}, + MessageData: map[string]interface{}{ + "name": "Person", + "items": map[string]interface{}{ + "name": "shoes", + "price": "59.99", + }, + "products": []interface{}{}, + }, + PlaintextBody: "This is an example body!", + ReplyTo: "noreply@example.com", + Subject: "Customer io test email", + To: "bob@example.com", + } + + // Start the email request (with template) + emailRequestWithTemplate := &EmailRequest{ + TransactionalMessageID: "123", + Identifiers: map[string]string{"id": "123"}, + MessageData: map[string]interface{}{ + "name": "Person", + "items": map[string]interface{}{ + "name": "shoes", + "price": "59.99", + }, + "products": []interface{}{}, + }, + To: "bob@example.com", + } + + t.Run("successful response (no template)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestNoTemplate) + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("successful response (with template)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestWithTemplate) + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("missing email request", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + var resp *EmailResponse + resp, err = client.SendEmail(nil) + assert.Error(t, err) + assert.Nil(t, resp) + }) + + t.Run("template - missing to", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestWithTemplate.To = "" + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestWithTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailTo") + }) + + t.Run("template - missing identifiers", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestWithTemplate.Identifiers = nil + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestWithTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailIdentifiers") + }) + + t.Run("no template - missing to", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestNoTemplate.To = "" + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestNoTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailTo") + }) + + t.Run("no template - missing identifiers", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestNoTemplate.Identifiers = nil + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestNoTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailIdentifiers") + }) + + t.Run("no template - missing body", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestNoTemplate.Body = "" + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestNoTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailBody") + }) + + t.Run("no template - missing subject", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestNoTemplate.Subject = "" + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestNoTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailSubject") + }) + + t.Run("no template - missing from", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusOK) + + emailRequestNoTemplate.From = "" + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestNoTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + checkParamError(t, err, "emailFrom") + }) + + t.Run("email error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockSendEmail(http.StatusUnprocessableEntity) + + var resp *EmailResponse + resp, err = client.SendEmail(emailRequestWithTemplate) + assert.Error(t, err) + assert.Nil(t, resp) + }) +} + +// ExampleClient_SendEmail example using SendEmail() +// +// See more examples in /examples/ +func ExampleClient_SendEmail() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockSendEmail(http.StatusOK) + + // Start the email request (with template) + emailRequestWithTemplate := &EmailRequest{ + TransactionalMessageID: "123", + Identifiers: map[string]string{"id": "123"}, + MessageData: map[string]interface{}{ + "name": "Person", + "items": map[string]interface{}{ + "name": "shoes", + "price": "59.99", + }, + "products": []interface{}{}, + }, + To: "bob@example.com", + } + + // Send email + _, err = client.SendEmail(emailRequestWithTemplate) + if err != nil { + fmt.Printf("error sending email: " + err.Error()) + return + } + fmt.Printf("email sent to: %s", emailRequestWithTemplate.To) + // Output:email sent to: bob@example.com +} + +// BenchmarkClient_SendEmail benchmarks the method SendEmail() +func BenchmarkClient_SendEmail(b *testing.B) { + client, _ := newTestClient() + mockSendEmail(http.StatusOK) + emailRequestWithTemplate := &EmailRequest{ + TransactionalMessageID: "123", + Identifiers: map[string]string{"id": "123"}, + MessageData: map[string]interface{}{ + "name": "Person", + "items": map[string]interface{}{ + "name": "shoes", + "price": "59.99", + }, + "products": []interface{}{}, + }, + To: "bob@example.com", + } + for i := 0; i < b.N; i++ { + _, _ = client.SendEmail(emailRequestWithTemplate) + } +} + +// mockSendEmail is used for mocking the response +func mockSendEmail(statusCode int) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("%sv1/send/email", testAppAPIURL), + httpmock.NewStringResponder( + statusCode, `{"delivery_id": "1234567890","queued_at": 1620313799}`, + ), + ) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..acaa14d --- /dev/null +++ b/errors.go @@ -0,0 +1,23 @@ +package customerio + +import "fmt" + +// APIError is returned by any method that fails at the API level +type APIError struct { + body []byte + status int + url string +} + +// Error is used to display the error message +func (a *APIError) Error() string { + return fmt.Sprintf("%v: %v %v", a.status, a.url, string(a.body)) +} + +// ParamError is an error returned if a parameter to the track API is invalid. +type ParamError struct { + Param string // Param is the name of the parameter. +} + +// Error is used to display the error message +func (p ParamError) Error() string { return p.Param + ": missing" } diff --git a/events.go b/events.go new file mode 100644 index 0000000..6e34b1f --- /dev/null +++ b/events.go @@ -0,0 +1,63 @@ +package customerio + +import ( + "fmt" + "net/http" + "net/url" + "time" +) + +// NewEvent will create a new event for the supplied customer +// See: https://customer.io/docs/api/#tag/Track-Events +// AKA: Track() +// Only use "email" if the workspace is setup to use email instead of ID +// Use "timestamp" to send events in the past. If not set, it will use Now().UTC() +func (c *Client) NewEvent(customerIDOrEmail string, eventName string, timestamp time.Time, + data map[string]interface{}) error { + if customerIDOrEmail == "" { + return ParamError{Param: "customerIDOrEmail"} + } + if eventName == "" { + return ParamError{Param: "eventName"} + } + if timestamp.IsZero() { + timestamp = time.Now().UTC() + } + _, err := c.request( + http.MethodPost, + fmt.Sprintf("%s/api/v1/customers/%s/events", c.options.trackURL, url.PathEscape(customerIDOrEmail)), + http.StatusOK, + map[string]interface{}{ + "data": data, + "name": eventName, + "timestamp": timestamp.Unix(), + // "type": "", (set to Page for a page view) // todo: add support for this feature + }, + ) + return err +} + +// NewAnonymousEvent will create a new event for the anonymous visitor +// See: https://customer.io/docs/api/#operation/trackAnonymous +// AKA: TrackAnonymous() +// Use "timestamp" to send events in the past. If not set, it will use Now().UTC() +func (c *Client) NewAnonymousEvent(eventName string, timestamp time.Time, data map[string]interface{}) error { + if eventName == "" { + return ParamError{Param: "eventName"} + } + if timestamp.IsZero() { + timestamp = time.Now().UTC() + } + _, err := c.request( + http.MethodPost, + fmt.Sprintf("%s/api/v1/events", c.options.trackURL), + http.StatusOK, + map[string]interface{}{ + "data": data, + "name": eventName, + "timestamp": timestamp.Unix(), + // "type": "", (set to Page for a page view) // todo: add support for this feature + }, + ) + return err +} diff --git a/events_test.go b/events_test.go new file mode 100644 index 0000000..4df9333 --- /dev/null +++ b/events_test.go @@ -0,0 +1,287 @@ +package customerio + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +// TestClient_NewEvent will test the method NewEvent() +func TestClient_NewEvent(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewEvent(http.StatusOK, testCustomerID) + + err = client.NewEvent( + testCustomerID, testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.NoError(t, err) + }) + + t.Run("missing customer id", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewEvent(http.StatusOK, testCustomerID) + + err = client.NewEvent( + "", testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.Error(t, err) + checkParamError(t, err, "customerIDOrEmail") + }) + + t.Run("missing event name", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewEvent(http.StatusOK, testCustomerID) + + err = client.NewEvent( + testCustomerID, "", time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.Error(t, err) + checkParamError(t, err, "eventName") + }) + + t.Run("no time set", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewEvent(http.StatusOK, testCustomerID) + + err = client.NewEvent( + testCustomerID, testEventName, time.Time{}, + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.NoError(t, err) + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewEvent(http.StatusUnprocessableEntity, testCustomerID) + + err = client.NewEvent( + testCustomerID, testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.Error(t, err) + }) +} + +// ExampleClient_NewEvent example using NewEvent() +// +// See more examples in /examples/ +func ExampleClient_NewEvent() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockNewEvent(http.StatusOK, testCustomerID) + + // New event + err = client.NewEvent( + testCustomerID, testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + if err != nil { + fmt.Printf("error creating event: " + err.Error()) + return + } + fmt.Printf("event created: %s", testEventName) + // Output:event created: test_event +} + +// BenchmarkClient_NewEvent benchmarks the method NewEvent() +func BenchmarkClient_NewEvent(b *testing.B) { + client, _ := newTestClient() + mockNewEvent(http.StatusOK, testCustomerID) + data := map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + } + timestamp := time.Now().UTC() + for i := 0; i < b.N; i++ { + _ = client.NewEvent(testCustomerID, testEventName, timestamp, data) + } +} + +// TestClient_NewAnonymousEvent will test the method NewAnonymousEvent() +func TestClient_NewAnonymousEvent(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewAnonymousEvent(http.StatusOK) + + err = client.NewAnonymousEvent( + testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.NoError(t, err) + }) + + t.Run("missing event name", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewAnonymousEvent(http.StatusOK) + + err = client.NewAnonymousEvent( + "", time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.Error(t, err) + checkParamError(t, err, "eventName") + }) + + t.Run("no time set", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewAnonymousEvent(http.StatusOK) + + err = client.NewAnonymousEvent( + testEventName, time.Time{}, + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.NoError(t, err) + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewAnonymousEvent(http.StatusUnprocessableEntity) + + err = client.NewEvent( + testCustomerID, testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + assert.Error(t, err) + }) +} + +// ExampleClient_NewAnonymousEvent example using NewAnonymousEvent() +// +// See more examples in /examples/ +func ExampleClient_NewAnonymousEvent() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockNewAnonymousEvent(http.StatusOK) + + // New event + err = client.NewAnonymousEvent( + testEventName, time.Now().UTC(), + map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + }) + if err != nil { + fmt.Printf("error creating event: " + err.Error()) + return + } + fmt.Printf("event created: %s", testEventName) + // Output:event created: test_event +} + +// BenchmarkClient_NewAnonymousEvent benchmarks the method NewAnonymousEvent() +func BenchmarkClient_NewAnonymousEvent(b *testing.B) { + client, _ := newTestClient() + mockNewAnonymousEvent(http.StatusOK) + data := map[string]interface{}{ + "field_name": "some_value", + "int_field": 123, + "timestamp_field": time.Now().UTC().Unix(), + } + timestamp := time.Now().UTC() + for i := 0; i < b.N; i++ { + _ = client.NewAnonymousEvent(testEventName, timestamp, data) + } +} + +// mockNewEvent is used for mocking the response +func mockNewEvent(statusCode int, customerID string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("%sapi/v1/customers/%s/events", testTrackingAPIURL, customerID), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockNewAnonymousEvent is used for mocking the response +func mockNewAnonymousEvent(statusCode int) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("%sapi/v1/events", testTrackingAPIURL), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} diff --git a/examples/auth_find_region/find_region.go b/examples/auth_find_region/find_region.go new file mode 100644 index 0000000..7ef1a47 --- /dev/null +++ b/examples/auth_find_region/find_region.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "os" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Find the region + var region *customerio.RegionInfo + region, err = client.FindRegion() + if err != nil { + log.Fatalln(err) + } + log.Printf("Region found! %+v\\n", region) +} diff --git a/examples/auth_test/test_auth.go b/examples/auth_test/test_auth.go new file mode 100644 index 0000000..6049fd8 --- /dev/null +++ b/examples/auth_test/test_auth.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "os" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with both Tracking) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Test authentication + err = client.TestAuth() + if err != nil { + log.Fatalln(err) + } + log.Println("Authentication Successful!") +} diff --git a/examples/customer_delete_via_id/delete_via_id.go b/examples/customer_delete_via_id/delete_via_id.go new file mode 100644 index 0000000..a07b4ad --- /dev/null +++ b/examples/customer_delete_via_id/delete_via_id.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "os" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Deleting the customer + err = client.DeleteCustomer("123") + if err != nil { + log.Fatalln(err) + } + log.Println("Customer Deleted Successfully!") +} diff --git a/examples/customer_update_via_email/update_via_email.go b/examples/customer_update_via_email/update_via_email.go new file mode 100644 index 0000000..c1675f9 --- /dev/null +++ b/examples/customer_update_via_email/update_via_email.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Update the customer + err = client.UpdateCustomerUsingEmail("bob@example.com", map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": "bob@example.com", + "first_name": "Bob", + "plan": "basic", + }) + if err != nil { + log.Fatalln(err) + } + log.Println("Customer Updated Successfully!") +} diff --git a/examples/customer_update_via_id/update_via_id.go b/examples/customer_update_via_id/update_via_id.go new file mode 100644 index 0000000..1e8d39a --- /dev/null +++ b/examples/customer_update_via_id/update_via_id.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Updating the customer + err = client.UpdateCustomer("123", map[string]interface{}{ + "created_at": time.Now().Unix(), + "email": "bob@example.com", + "first_name": "Bob", + "plan": "basic", + }) + if err != nil { + log.Fatalln(err) + } + log.Println("Customer Updated Successfully!") +} diff --git a/examples/device_delete_via_id/delete_via_id.go b/examples/device_delete_via_id/delete_via_id.go new file mode 100644 index 0000000..f46b76c --- /dev/null +++ b/examples/device_delete_via_id/delete_via_id.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "os" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Updating the device + err = client.DeleteDevice("123", "abcdefghijklmnopqrstuvwxyz") + if err != nil { + log.Fatalln(err) + } + log.Println("Device Deleted Successfully!") +} diff --git a/examples/device_update_via_id/update_via_id.go b/examples/device_update_via_id/update_via_id.go new file mode 100644 index 0000000..b81fdf1 --- /dev/null +++ b/examples/device_update_via_id/update_via_id.go @@ -0,0 +1,31 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Updating the device + err = client.UpdateDevice("123", &customerio.Device{ + ID: "abcdefghijklmnopqrstuvwxyz", + LastUsed: time.Now().UTC().Unix(), + Platform: customerio.PlatformAndroid, + }) + if err != nil { + log.Fatalln(err) + } + log.Println("Device Updated Successfully!") +} diff --git a/examples/new_event/new_event.go b/examples/new_event/new_event.go new file mode 100644 index 0000000..bb2a124 --- /dev/null +++ b/examples/new_event/new_event.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // New event + err = client.NewEvent( + "123", "order_completed", time.Now().UTC(), + map[string]interface{}{ + "order_id": "1234567", + "amount": "99.99", + }) + if err != nil { + log.Fatalln(err) + } + log.Println("Event Created Successfully!") +} diff --git a/examples/send_email/send_email.go b/examples/send_email/send_email.go new file mode 100644 index 0000000..fa6c085 --- /dev/null +++ b/examples/send_email/send_email.go @@ -0,0 +1,60 @@ +package main + +import ( + "log" + "os" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API & App API enabled) + client, err := customerio.NewClient( + customerio.WithTrackingKey(os.Getenv("TRACKING_SITE_ID"), os.Getenv("TRACKING_API_KEY")), + customerio.WithAppKey(os.Getenv("APP_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + // Start the email request + emailRequest := &customerio.EmailRequest{ + Body: "This is an example body!", + From: "noreply@example.com", + Identifiers: map[string]string{"id": "123"}, + MessageData: map[string]interface{}{ + "name": "Person", + "items": map[string]interface{}{ + "name": "shoes", + "price": "59.99", + }, + "products": []interface{}{}, + }, + PlaintextBody: "This is an example body!", + ReplyTo: "noreply@example.com", + Subject: "Customer io test email", + To: "bob@example.com", + } + + // Attach a file (example) + /* + var f *os.File + f, err = os.Open("") + if err != nil { + log.Fatalln(err) + } + defer func() { + _ = f.Close() + }() + if err = emailRequest.Attach("sample.pdf", f); err != nil { + log.Fatalln(err) + } + */ + + // Send an email (NOT using a template) + if _, err = client.SendEmail(emailRequest); err != nil { + log.Fatalln(err) + } + log.Println("Email Sent Successfully!") +} diff --git a/examples/transactional.go b/examples/transactional.go deleted file mode 100644 index 10f561b..0000000 --- a/examples/transactional.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - - "github.com/customerio/go-customerio" -) - -func main() { - - ctx := context.Background() - - client := customerio.NewAPIClient("", customerio.WithRegion(customerio.RegionUS)) - - req := customerio.SendEmailRequest{ - Identifiers: map[string]string{ - "id": "customer_1", - }, - To: "customer@example.com", - From: "business@example.com", - Subject: "hello, {{ trigger.name }}", - Body: "hello from the Customer.io {{ trigger.client }} client", - MessageData: map[string]interface{}{ - "client": "Go", - "name": "gopher", - }, - } - - f, err := os.Open("") - if err != nil { - panic(err) - } - defer f.Close() - - if err := req.Attach("sample.pdf", f); err != nil { - panic(err) - } - - resp, err := client.SendEmail(ctx, &req) - if err != nil { - panic(err) - } - - fmt.Println(resp) -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b38f467 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/mrz1836/go-customerio + +go 1.13 + +require ( + github.com/go-resty/resty/v2 v2.6.0 + github.com/jarcoal/httpmock v1.0.8 + github.com/stretchr/testify v1.7.0 + golang.org/x/net v0.0.0-20210504132125-bbd867fde50d // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aada7ae --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4= +github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= +github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k= +github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210504132125-bbd867fde50d h1:nTDGCTeAu2LhcsHTRzjyIUbZHCJ4QePArsm27Hka0UM= +golang.org/x/net v0.0.0-20210504132125-bbd867fde50d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/options.go b/options.go deleted file mode 100644 index 28ae1ef..0000000 --- a/options.go +++ /dev/null @@ -1,46 +0,0 @@ -package customerio - -import "net/http" - -type option struct { - api func(*APIClient) - track func(*CustomerIO) -} - -type region struct { - apiURL string - trackURL string -} - -var ( - RegionUS = region{ - apiURL: "https://api.customer.io", - trackURL: "https://track.customer.io", - } - RegionEU = region{ - apiURL: "https://api-eu.customer.io", - trackURL: "https://track-eu.customer.io", - } -) - -func WithRegion(r region) option { - return option{ - api: func(a *APIClient) { - a.URL = r.apiURL - }, - track: func(c *CustomerIO) { - c.URL = r.trackURL - }, - } -} - -func WithHTTPClient(client *http.Client) option { - return option{ - api: func(a *APIClient) { - a.Client = client - }, - track: func(c *CustomerIO) { - c.Client = client - }, - } -} diff --git a/options_test.go b/options_test.go deleted file mode 100644 index 2f6389f..0000000 --- a/options_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package customerio - -import ( - "net/http" - "reflect" - "testing" -) - -func TestAPIOptions(t *testing.T) { - - client := NewAPIClient("mykey") - if client.URL != RegionUS.apiURL { - t.Errorf("wrong default url. got: %s, want: %s", client.URL, RegionUS.apiURL) - } - - client = NewAPIClient("mykey", WithRegion(RegionEU)) - if client.URL != RegionEU.apiURL { - t.Errorf("wrong url. got: %s, want: %s", client.URL, RegionEU.apiURL) - } - - hc := &http.Client{} - client = NewAPIClient("mykey", WithHTTPClient(hc)) - if !reflect.DeepEqual(client.Client, hc) { - t.Errorf("wrong http client. got: %#v, want: %#v", client.Client, hc) - } -} - -func TestTrackOptions(t *testing.T) { - - client := NewTrackClient("site_id", "api_key") - if client.URL != RegionUS.trackURL { - t.Errorf("wrong default url. got: %s, want: %s", client.URL, RegionUS.trackURL) - } - - client = NewTrackClient("site_id", "api_key", WithRegion(RegionEU)) - if client.URL != RegionEU.trackURL { - t.Errorf("wrong url. got: %s, want: %s", client.URL, RegionEU.trackURL) - } - - hc := &http.Client{} - client = NewTrackClient("site_id", "api_key", WithHTTPClient(hc)) - if !reflect.DeepEqual(client.Client, hc) { - t.Errorf("wrong http client. got: %#v, want: %#v", client.Client, hc) - } -} diff --git a/send_email.go b/send_email.go deleted file mode 100644 index 6291a34..0000000 --- a/send_email.go +++ /dev/null @@ -1,90 +0,0 @@ -package customerio - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" -) - -type SendEmailRequest struct { - MessageData map[string]interface{} `json:"message_data,omitempty"` - TransactionalMessageID string `json:"transactional_message_id,omitempty"` - Identifiers map[string]string `json:"identifiers"` - Headers map[string]string `json:"headers,omitempty"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - ReplyTo string `json:"reply_to,omitempty"` - BCC string `json:"bcc,omitempty"` - Subject string `json:"subject,omitempty"` - Preheader string `json:"preheader,omitempty"` - Body string `json:"body,omitempty"` - PlaintextBody string `json:"plaintext_body,omitempty"` - AMPBody string `json:"amp_body,omitempty"` - FakeBCC *bool `json:"fake_bcc,omitempty"` - Attachments map[string]string `json:"attachments,omitempty"` - DisableMessageRetention *bool `json:"disable_message_retention,omitempty"` - SendToUnsubscribed *bool `json:"send_to_unsubscribed,omitempty"` - EnableTracking *bool `json:"tracked,omitempty"` - QueueDraft *bool `json:"queue_draft,omitempty"` -} - -var ErrAttachmentExists = errors.New("attachment with this name already exists") - -func (e *SendEmailRequest) Attach(name string, value io.Reader) error { - if e.Attachments == nil { - e.Attachments = map[string]string{} - } - if _, ok := e.Attachments[name]; ok { - return ErrAttachmentExists - } - - var buf bytes.Buffer - enc := base64.NewEncoder(base64.StdEncoding, &buf) - if _, err := io.Copy(enc, value); err != nil { - return err - } - - e.Attachments[name] = string(buf.Bytes()) - return nil -} - -type SendEmailResponse struct { - TransactionalResponse -} - -// SendEmail sends a single transactional email using the Customer.io transactional API -func (c *APIClient) SendEmail(ctx context.Context, req *SendEmailRequest) (*SendEmailResponse, error) { - body, statusCode, err := c.doRequest(ctx, "POST", "/v1/send/email", req) - if err != nil { - return nil, err - } - - if statusCode != http.StatusOK { - var meta struct { - Meta struct { - Err string `json:"error"` - } `json:"meta"` - } - if err := json.Unmarshal(body, &meta); err != nil { - return nil, &TransactionalError{ - StatusCode: statusCode, - Err: string(body), - } - } - return nil, &TransactionalError{ - StatusCode: statusCode, - Err: meta.Meta.Err, - } - } - - var result SendEmailResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, err - } - - return &result, nil -} diff --git a/send_email_test.go b/send_email_test.go deleted file mode 100644 index 66800d8..0000000 --- a/send_email_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package customerio_test - -import ( - "context" - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "reflect" - "testing" - "time" - - "github.com/customerio/go-customerio" -) - -func TestSendEmail(t *testing.T) { - emailRequest := &customerio.SendEmailRequest{ - Identifiers: map[string]string{ - "id": "customer_1", - }, - To: "customer@example.com", - From: "business@example.com", - Subject: "hello, {{ trigger.name }}", - Body: "hello from the Customer.io {{ trigger.client }} client", - MessageData: map[string]interface{}{ - "client": "Go", - "name": "gopher", - }, - } - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - b, err := ioutil.ReadAll(req.Body) - if err != nil { - t.Error(err) - } - defer req.Body.Close() - - var body customerio.SendEmailRequest - if err := json.Unmarshal(b, &body); err != nil { - t.Error(err) - } - - if !reflect.DeepEqual(&body, emailRequest) { - t.Errorf("Request differed, want: %#v, got: %#v", emailRequest, body) - } - - w.Write([]byte(`{ - "delivery_id": "ABCDEFG", - "queued_at": 1500111111 - }`)) - })) - defer srv.Close() - - api := customerio.NewAPIClient("myKey") - api.URL = srv.URL - - resp, err := api.SendEmail(context.Background(), emailRequest) - if err != nil { - t.Error(err) - } - - expect := &customerio.SendEmailResponse{ - TransactionalResponse: customerio.TransactionalResponse{ - DeliveryID: "ABCDEFG", - QueuedAt: time.Unix(1500111111, 0), - }, - } - - if !reflect.DeepEqual(resp, expect) { - t.Errorf("Expect: %#v, Got: %#v", expect, resp) - } -} - -func TestSendEmailError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(502) - })) - defer srv.Close() - - api := customerio.NewAPIClient("myKey") - api.URL = srv.URL - - resp, err := api.SendEmail(context.Background(), &customerio.SendEmailRequest{ - Identifiers: map[string]string{ - "id": "customer_1", - }, - To: "customer@example.com", - From: "business@example.com", - Subject: "hello, {{ trigger.name }}", - Body: "hello from the Customer.io {{ trigger.client }} client", - MessageData: map[string]interface{}{ - "client": "Go", - "name": "gopher", - }, - }) - if err == nil { - t.Errorf("Expected error, got: %#v", resp) - } - - if e, ok := err.(*customerio.TransactionalError); !ok { - t.Errorf("Expected TransactionalError, got: %#v", e) - } -} diff --git a/transactional.go b/transactional.go deleted file mode 100644 index dcdc978..0000000 --- a/transactional.go +++ /dev/null @@ -1,39 +0,0 @@ -package customerio - -import ( - "encoding/json" - "time" -) - -// TransactionalResponse is a response to the send of a transactional message. -type TransactionalResponse struct { - // DeliveryID is a unique id for the given message. - DeliveryID string `json:"delivery_id"` - // QueuedAt is when the message was queued. - QueuedAt time.Time `json:"queued_at"` -} - -func (t *TransactionalResponse) UnmarshalJSON(b []byte) error { - var r struct { - DeliveryID string `json:"delivery_id"` - QueuedAt int64 `json:"queued_at"` - } - if err := json.Unmarshal(b, &r); err != nil { - return err - } - t.DeliveryID = r.DeliveryID - t.QueuedAt = time.Unix(r.QueuedAt, 0) - return nil -} - -// TransactionalError is returned if a transactional message fails to send. -type TransactionalError struct { - // Err is a more specific error message. - Err string - // StatusCode is the http status code for the error. - StatusCode int -} - -func (e *TransactionalError) Error() string { - return e.Err -}