├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── initiate_release.yml │ ├── release.yml │ └── reviewdog.yml ├── .gitignore ├── .golangci.yml ├── .versionrc.js ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── activity.go ├── activity_test.go ├── aggregated_feed.go ├── aggregated_feed_test.go ├── analytics.go ├── analytics_test.go ├── analytics_types.go ├── assets └── logo.svg ├── audit_logs.go ├── audit_logs_test.go ├── authenticator.go ├── authenticator_test.go ├── client.go ├── client_internal_test.go ├── client_test.go ├── collections.go ├── collections_test.go ├── enriched_activities.go ├── enriched_activities_test.go ├── errors.go ├── errors_test.go ├── feed.go ├── feed_test.go ├── flat_feed.go ├── flat_feed_test.go ├── go.mod ├── go.sum ├── moderation.go ├── moderation_test.go ├── notification_feed.go ├── notification_feed_test.go ├── options.go ├── personalization.go ├── personalization_test.go ├── reactions.go ├── reactions_test.go ├── run-lint.sh ├── scripts └── get_changelog_diff.js ├── stream.go ├── types.go ├── types_internal_test.go ├── types_test.go ├── url.go ├── url_internal_test.go ├── users.go ├── users_test.go ├── utils.go ├── utils_internal_test.go ├── utils_test.go └── version.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @JimmyPettersson85 @xernobyl @itsmeadi 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | pull_request: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test-build: 14 | name: 👷 Test & Build 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | goVer: ["1.22", "1.23", "1.24"] 19 | steps: 20 | - name: Set up Go ${{ matrix.goVer }} 21 | uses: actions/setup-go@v3 22 | with: 23 | go-version: ${{ matrix.goVer }} 24 | id: go 25 | 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Lint and Test via ${{ matrix.goVer }} 30 | env: 31 | STREAM_API_KEY: ${{ secrets.STREAM_API_KEY }} 32 | STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} 33 | STREAM_API_REGION: ${{ secrets.STREAM_API_REGION }} 34 | STREAM_API_VERSION: ${{ secrets.STREAM_API_VERSION }} 35 | run: | 36 | go mod tidy -v && git diff --no-patch --exit-code 37 | go test -v -race ./... 38 | -------------------------------------------------------------------------------- /.github/workflows/initiate_release.yml: -------------------------------------------------------------------------------- 1 | name: Create release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "The new version number with 'v' prefix. Example: v1.40.1" 8 | required: true 9 | 10 | jobs: 11 | init_release: 12 | name: 🚀 Create release PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 # gives the changelog generator access to all previous commits 18 | 19 | - name: Update CHANGELOG.md, version.go and push release branch 20 | env: 21 | VERSION: ${{ github.event.inputs.version }} 22 | run: | 23 | npx --yes standard-version@9.3.2 --release-as "$VERSION" --skip.tag --skip.commit --tag-prefix=v 24 | git config --global user.name 'github-actions' 25 | git config --global user.email 'release@getstream.io' 26 | git checkout -q -b "release-$VERSION" 27 | git commit -am "chore(release): $VERSION" 28 | git push -q -u origin "release-$VERSION" 29 | 30 | - name: Get changelog diff 31 | uses: actions/github-script@v6 32 | with: 33 | script: | 34 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 35 | core.exportVariable('CHANGELOG', get_change_log_diff()) 36 | 37 | - name: Open pull request 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | gh pr create \ 42 | -t "chore(release): ${{ github.event.inputs.version }}" \ 43 | -b "# :rocket: ${{ github.event.inputs.version }} 44 | Make sure to use squash & merge when merging! 45 | Once this is merged, another job will kick off automatically and publish the package. 46 | # :memo: Changelog 47 | ${{ env.CHANGELOG }}" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: 7 | - main 8 | 9 | jobs: 10 | Release: 11 | name: 🚀 Release 12 | if: github.event.pull_request.merged && startsWith(github.head_ref, 'release-') 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/github-script@v6 20 | with: 21 | script: | 22 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 23 | core.exportVariable('CHANGELOG', get_change_log_diff()) 24 | 25 | // Getting the release version from the PR source branch 26 | // Source branch looks like this: release-1.0.0 27 | const version = context.payload.pull_request.head.ref.split('-')[1] 28 | core.exportVariable('VERSION', version) 29 | 30 | - name: Create release on GitHub 31 | uses: ncipollo/release-action@v1 32 | with: 33 | body: ${{ env.CHANGELOG }} 34 | tag: ${{ env.VERSION }} 35 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: 3 | pull_request: 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | reviewdog: 11 | name: 🐶 Reviewdog 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - uses: reviewdog/action-setup@v1 17 | with: 18 | reviewdog_version: latest 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v3 22 | with: 23 | go-version: "1.22" 24 | 25 | - name: Install golangci-lint 26 | run: 27 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.54.1 28 | 29 | - name: Reviewdog 30 | env: 31 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: 33 | ./run-lint.sh | reviewdog -f=golangci-lint -name=golangci-lint -reporter=github-pr-review 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # coverage 14 | coverage.txt 15 | 16 | .vscode/ 17 | .envrc 18 | 19 | # vendor dependencies generated using go mod 20 | vendor/ 21 | test/ 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 210s 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - bodyclose 8 | - dupl 9 | - errcheck 10 | - gas 11 | - gci 12 | - goconst 13 | - gocritic 14 | - gocyclo 15 | - gofmt 16 | - gofumpt 17 | - goimports 18 | - gosimple 19 | - govet 20 | - ineffassign 21 | - misspell 22 | - revive 23 | - staticcheck 24 | - unconvert 25 | - unused 26 | 27 | linters-settings: 28 | goconst: 29 | min-len: 5 30 | min-occurrences: 5 31 | gofmt: 32 | simplify: true 33 | gofumpt: 34 | simplify: true 35 | goimports: 36 | local-prefixes: github.com/GetStream/stream-go2 37 | gocritic: 38 | disabled-checks: 39 | - whyNoLint 40 | enabled-tags: 41 | - diagnostic 42 | - experimental 43 | - opinionated 44 | - performance 45 | - style 46 | settings: 47 | hugeParam: 48 | sizeThreshold: 364 49 | rangeValCopy: 50 | sizeThreshold: 364 51 | skipTestFuncs: true 52 | govet: 53 | enable-all: true 54 | disable: 55 | - shadow 56 | gci: 57 | sections: 58 | - Standard 59 | - Default 60 | - Prefix(github.com/GetStream/stream-go2) 61 | 62 | issues: 63 | exclude-rules: 64 | - path: _test\.go 65 | linters: 66 | - dupl 67 | - path: _feed\.go # should reuse code between aggregated and notification feeds 68 | linters: 69 | - dupl 70 | - text: 'fieldalignment:' 71 | linters: 72 | - govet 73 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | const versionFileUpdater = { 2 | REGEX: /Version = "v([0-9.]+)"/, 3 | 4 | readVersion: function (contents) { 5 | return this.REGEX.exec(contents)[1]; 6 | }, 7 | 8 | writeVersion: function (contents, version) { 9 | const splitted = version.split('.'); 10 | const [major, minor, patch] = [splitted[0], splitted[1], splitted[2]]; 11 | 12 | return contents.replace(this.REGEX, `Version = "v${major}.${minor}.${patch}"`); 13 | } 14 | } 15 | 16 | const moduleVersionUpdater = { 17 | GO_MOD_REGEX: /stream-go2\/v(\d+)/g, 18 | 19 | readVersion: function (contents) { 20 | return this.GO_MOD_REGEX.exec(contents)[1]; 21 | }, 22 | 23 | writeVersion: function (contents, version) { 24 | const major = version.split('.')[0]; 25 | const previousMajor = major - 1; 26 | const go_mod_regex = new RegExp(`stream-go2\/v(${previousMajor})`, "g"); 27 | 28 | return contents.replace(go_mod_regex, `stream-go2/v${major}`); 29 | } 30 | } 31 | 32 | module.exports = { 33 | bumpFiles: [ 34 | { filename: './version.go', updater: versionFileUpdater }, 35 | { filename: './go.mod', updater: moduleVersionUpdater }, 36 | { filename: './README.md', updater: moduleVersionUpdater }, 37 | ], 38 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [8.8.0](https://github.com/GetStream/stream-go2/compare/v8.7.0...v8.8.0) (2025-03-20) 6 | 7 | ## [8.7.0](https://github.com/GetStream/stream-go2/compare/v8.6.0...v8.7.0) (2025-03-19) 8 | 9 | ## [8.6.0](https://github.com/GetStream/stream-go2/compare/v8.5.0...v8.6.0) (2025-03-07) 10 | 11 | ## [8.5.0](https://github.com/GetStream/stream-go2/compare/v8.4.3...v8.5.0) (2025-03-05) 12 | 13 | ### [8.4.3](https://github.com/GetStream/stream-go2/compare/v8.4.2...v8.4.3) (2024-06-25) 14 | 15 | ### [8.4.2](https://github.com/GetStream/stream-go2/compare/v8.4.1...v8.4.2) (2024-06-18) 16 | 17 | ### [8.4.1](https://github.com/GetStream/stream-go2/compare/v8.4.0...v8.4.1) (2024-06-14) 18 | 19 | ## [8.4.0](https://github.com/GetStream/stream-go2/compare/v8.3.0...v8.4.0) (2024-01-09) 20 | 21 | ## [8.3.0](https://github.com/GetStream/stream-go2/compare/v8.2.1...v8.3.0) (2023-11-20) 22 | 23 | ### [8.2.1](https://github.com/GetStream/stream-go2/compare/v8.2.0...v8.2.1) (2023-08-17) 24 | 25 | ## [8.2.0](https://github.com/GetStream/stream-go2/compare/v8.1.0...v8.2.0) (2023-08-17) 26 | 27 | ## [8.1.0](https://github.com/GetStream/stream-go2/compare/v8.0.2...v8.1.0) (2023-07-25) 28 | 29 | ### [8.0.2](https://github.com/GetStream/stream-go2/compare/v8.0.1...v8.0.2) (2023-05-10) 30 | 31 | ### [8.0.1](https://github.com/GetStream/stream-go2/compare/v8.0.0...v8.0.1) (2023-02-13) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * link to correct repo ([c771c1f](https://github.com/GetStream/stream-go2/commit/c771c1fe49c1ae1ef502fd3015383effe0bbc317)) 37 | * use v8 in tests ([a9406ad](https://github.com/GetStream/stream-go2/commit/a9406adb46678089d6e957299efcfea134494334)) 38 | 39 | ## [8.0.0](https://github.com/GetStream/stream-go2/compare/v7.1.0...v8.0.0) (2022-10-20) 40 | 41 | 42 | ### Features 43 | 44 | * add user enrichment for reactions ([#137](https://github.com/GetStream/stream-go2/issues/137)) ([d88c659](https://github.com/GetStream/stream-go2/commit/d88c659dd5520cdd9bc8388912857834f0b4086b)) 45 | 46 | ## [7.1.0](https://github.com/GetStream/stream-go2/compare/v7.0.1...v7.1.0) (2022-10-04) 47 | 48 | 49 | ### Features 50 | 51 | * add time fields to reactions ([#134](https://github.com/GetStream/stream-go2/issues/134)) ([bd5966c](https://github.com/GetStream/stream-go2/commit/bd5966c3eb5930cd050844412fe093060ad64222)) 52 | 53 | ### [7.0.1](https://github.com/GetStream/stream-go2/compare/v7.0.0...v7.0.1) (2022-06-21) 54 | 55 | 56 | ### ⚠ BREAKING CHANGES 57 | 58 | * rename session token methods (#126) 59 | 60 | ### Features 61 | 62 | * **go_version:** bump to v1.17 ([#125](https://github.com/GetStream/stream-go2/issues/125)) ([0c1e87c](https://github.com/GetStream/stream-go2/commit/0c1e87c0451859787d95de11a955253d8ee00b49)) 63 | 64 | 65 | * rename session token methods ([#126](https://github.com/GetStream/stream-go2/issues/126)) ([39fbcf7](https://github.com/GetStream/stream-go2/commit/39fbcf75c16aa26c70c12afbd5d4d9faab8d5a4e)) 66 | 67 | ## [7.0.0](https://github.com/GetStream/stream-go2/compare/v6.4.2...v7.0.0) (2022-05-10) 68 | 69 | 70 | ### Features 71 | 72 | * **context:** add context as first argument ([#123](https://github.com/GetStream/stream-go2/issues/123)) ([9612a24](https://github.com/GetStream/stream-go2/commit/9612a24b921d4aeb8ab4b22e8c5ddd93e84ecf9e)) 73 | 74 | ## [6.4.2] 2022-03-10 75 | 76 | - Improve keep-alive settings of the default client. 77 | 78 | ## [6.4.1] 2022-03-09 79 | 80 | - Handle activity references in foreign id for enrichment. Enriched activity is put into `foreign_id_ref` under `Extra`. 81 | 82 | ## [6.4.0] 2021-12-15 83 | 84 | - Add new flags for reaction pagination 85 | - Fix parsing next url in reaction pagination 86 | 87 | ## [6.3.0] 2021-12-03 88 | 89 | - Add new reaction flags 90 | - first reactions 91 | - reaction count 92 | - own children kind filter 93 | 94 | ## [6.2.0] 2021-11-19 95 | 96 | - Add user id support into reaction own children for filtering 97 | 98 | ## [6.1.0] 2021-11-15 99 | 100 | - Expose created_at/updated_at in groups for aggregated/notification feeds 101 | 102 | ## [6.0.0] 2021-11-12 103 | 104 | - Add enrichment options into read activity endpoints 105 | - Move support into go 1.16 & 1.17 106 | 107 | ## [5.7.2] 2021-08-04 108 | 109 | - Dependency upgrade for unmaintained jwt 110 | 111 | ## [5.7.1] 2021-07-01 112 | 113 | - Fix godoc issues 114 | 115 | ## [5.7.0] 2021-06-04 116 | 117 | - Add follow stats endpoint support ([#108](https://github.com/GetStream/stream-go2/pull/108)) 118 | - Run CI with 1.15 and 1.16 119 | - Add a changelog to document changes 120 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # :recycle: Contributing 2 | 3 | Contributions to this project are very much welcome, please make sure that your code changes are tested and that they follow Go best-practices. 4 | 5 | ## Getting started 6 | 7 | ### Required environmental variables 8 | 9 | The tests require at least two environment variables: `STREAM_API_KEY` and `STREAM_API_SECRET`. There are multiple ways to provide that: 10 | - simply set it in your current shell (`export STREAM_API_KEY=xyz`) 11 | - you could use [direnv](https://direnv.net/) 12 | - if you debug the tests in VS Code, you can set up an env file there as well: `"go.testEnvFile": "${workspaceFolder}/.env"`. 13 | 14 | ### Code formatting & linter 15 | 16 | We enforce code formatting with [`gofumpt`](https://github.com/mvdan/gofumpt) (a stricter `gofmt`). If you use VS Code, it's recommended to set this setting there for auto-formatting: 17 | 18 | ```json 19 | { 20 | "editor.formatOnSave": true, 21 | "gopls": { 22 | "formatting.gofumpt": true 23 | }, 24 | "go.lintTool": "golangci-lint", 25 | "go.lintFlags": [ 26 | "--fast" 27 | ] 28 | } 29 | ``` 30 | 31 | Gofumpt will mostly take care of your linting issues as well. 32 | 33 | ## Commit message convention 34 | 35 | Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. 36 | You can read about conventional commits [here](https://www.conventionalcommits.org/). Here's how a usual commit message looks like for a new feature: `feat: allow provided config object to extend other configs`. A bugfix: `fix: prevent racing of requests`. 37 | 38 | ## Release (for Stream developers) 39 | 40 | Releasing this package involves two GitHub Action steps: 41 | 42 | - Kick off a job called `initiate_release` ([link](https://github.com/GetStream/stream-go2/actions/workflows/initiate_release.yml)). 43 | 44 | The job creates a pull request with the changelog. Check if it looks good. 45 | 46 | - Merge the pull request. 47 | 48 | Once the PR is merged, it automatically kicks off another job which will create the tag and created a GitHub release. 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2021, Stream.io Inc, and individual contributors. 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted 6 | provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of 9 | conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of 12 | conditions and the following disclaimer in the documentation and/or other materials 13 | provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its contributors may 16 | be used to endorse or promote products derived from this software without specific prior 17 | written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 20 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY 21 | AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 22 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 26 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /activity.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/fatih/structs" 8 | ) 9 | 10 | // Activity is a Stream activity entity. 11 | type Activity struct { 12 | ID string `json:"id,omitempty"` 13 | Actor string `json:"actor,omitempty"` 14 | Verb string `json:"verb,omitempty"` 15 | Object string `json:"object,omitempty"` 16 | ForeignID string `json:"foreign_id,omitempty"` 17 | Target string `json:"target,omitempty"` 18 | Time Time `json:"time,omitempty"` 19 | Origin string `json:"origin,omitempty"` 20 | To []string `json:"to,omitempty"` 21 | Score float64 `json:"score,omitempty"` 22 | Extra map[string]any `json:"-"` 23 | ScoreVars map[string]any `json:"score_vars,omitempty"` 24 | } 25 | 26 | // UnmarshalJSON decodes the provided JSON payload into the Activity. It's required 27 | // because of the custom JSON fields and time formats. 28 | func (a *Activity) UnmarshalJSON(b []byte) error { 29 | var data map[string]any 30 | if err := json.Unmarshal(b, &data); err != nil { 31 | return err 32 | } 33 | 34 | if _, ok := data["to"]; ok { 35 | tos := data["to"].([]any) 36 | simpleTos := make([]string, len(tos)) 37 | for i := range tos { 38 | switch to := tos[i].(type) { 39 | case string: 40 | simpleTos[i] = to 41 | case []any: 42 | tos, ok := to[0].(string) 43 | if !ok { 44 | return errors.New("invalid format for to targets") 45 | } 46 | simpleTos[i] = tos 47 | } 48 | } 49 | data["to"] = simpleTos 50 | } 51 | 52 | return a.decode(data) 53 | } 54 | 55 | // MarshalJSON encodes the Activity to a valid JSON bytes slice. It's required because of 56 | // the custom JSON fields and time formats. 57 | func (a Activity) MarshalJSON() ([]byte, error) { 58 | data := structs.New(a).Map() 59 | for k, v := range a.Extra { 60 | data[k] = v 61 | } 62 | if _, ok := data["time"]; ok { 63 | data["time"] = a.Time.Format(TimeLayout) 64 | } 65 | return json.Marshal(data) 66 | } 67 | 68 | func (a *Activity) decode(data map[string]any) error { 69 | meta, err := decodeData(data, a) 70 | if err != nil { 71 | return err 72 | } 73 | if len(meta.Unused) > 0 { 74 | a.Extra = make(map[string]any) 75 | for _, k := range meta.Unused { 76 | a.Extra[k] = data[k] 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | // baseActivityGroup is the common part of responses obtained from reading normal or enriched aggregated feeds. 83 | type baseActivityGroup struct { 84 | ActivityCount int `json:"activity_count,omitempty"` 85 | ActorCount int `json:"actor_count,omitempty"` 86 | Group string `json:"group,omitempty"` 87 | ID string `json:"id,omitempty"` 88 | Verb string `json:"verb,omitempty"` 89 | CreatedAt Time `json:"created_at,omitempty"` 90 | UpdatedAt Time `json:"updated_at,omitempty"` 91 | } 92 | 93 | // ActivityGroup is a group of Activity obtained from aggregated feeds. 94 | type ActivityGroup struct { 95 | baseActivityGroup 96 | Activities []Activity `json:"activities,omitempty"` 97 | } 98 | -------------------------------------------------------------------------------- /activity_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | stream "github.com/GetStream/stream-go2/v8" 12 | ) 13 | 14 | func TestActivityMarshalUnmarshalJSON(t *testing.T) { 15 | now := getTime(time.Now()) 16 | testCases := []struct { 17 | activity stream.Activity 18 | data []byte 19 | }{ 20 | { 21 | activity: stream.Activity{Actor: "actor", Verb: "verb", Object: "object"}, 22 | data: []byte(`{"actor":"actor","object":"object","verb":"verb"}`), 23 | }, 24 | { 25 | activity: stream.Activity{Actor: "actor", Verb: "verb", Object: "object", Time: now}, 26 | data: []byte(`{"actor":"actor","object":"object","time":"` + now.Format(stream.TimeLayout) + `","verb":"verb"}`), 27 | }, 28 | { 29 | activity: stream.Activity{Actor: "actor", Verb: "verb", Object: "object", Time: now, Extra: map[string]any{"popularity": 42.0, "size": map[string]any{"width": 800.0, "height": 600.0}}}, 30 | data: []byte(`{"actor":"actor","object":"object","popularity":42,"size":{"height":600,"width":800},"time":"` + now.Format(stream.TimeLayout) + `","verb":"verb"}`), 31 | }, 32 | { 33 | activity: stream.Activity{Actor: "actor", Verb: "verb", Object: "object", Time: now, Extra: map[string]any{"popularity": 42.0, "size": map[string]any{"width": 800.0, "height": 600.0}}}, 34 | data: []byte(`{"actor":"actor","object":"object","popularity":42,"size":{"height":600,"width":800},"time":"` + now.Format(stream.TimeLayout) + `","verb":"verb"}`), 35 | }, 36 | { 37 | activity: stream.Activity{To: []string{"abcd", "efgh"}}, 38 | data: []byte(`{"to":["abcd","efgh"]}`), 39 | }, 40 | } 41 | for _, tc := range testCases { 42 | data, err := json.Marshal(tc.activity) 43 | assert.NoError(t, err) 44 | assert.Equal(t, tc.data, data) 45 | 46 | var out stream.Activity 47 | err = json.Unmarshal(tc.data, &out) 48 | require.NoError(t, err) 49 | assert.Equal(t, tc.activity, out) 50 | } 51 | } 52 | 53 | func TestActivityMarshalUnmarshalJSON_toTargets(t *testing.T) { 54 | testCases := []struct { 55 | activity stream.Activity 56 | data []byte 57 | shouldError bool 58 | }{ 59 | { 60 | activity: stream.Activity{To: []string{"abcd", "efgh"}}, 61 | data: []byte(`{"to":["abcd","efgh"]}`), 62 | }, 63 | { 64 | activity: stream.Activity{To: []string{"abcd", "efgh"}}, 65 | data: []byte(`{"to":[["abcd", "foo"], ["efgh", "bar"]]}`), 66 | }, 67 | { 68 | activity: stream.Activity{To: []string{"abcd", "efgh"}}, 69 | data: []byte(`{"to":[[123]]}`), 70 | shouldError: true, 71 | }, 72 | } 73 | for _, tc := range testCases { 74 | var out stream.Activity 75 | err := json.Unmarshal(tc.data, &out) 76 | if tc.shouldError { 77 | require.Error(t, err) 78 | } else { 79 | require.NoError(t, err) 80 | assert.Equal(t, tc.activity, out) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /aggregated_feed.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | // AggregatedFeed is a Stream aggregated feed, which contains activities grouped 9 | // based on the grouping function defined on the dashboard. 10 | type AggregatedFeed struct { 11 | feed 12 | } 13 | 14 | // GetActivities requests and retrieves the activities and groups for the 15 | // aggregated feed. 16 | func (f *AggregatedFeed) GetActivities(ctx context.Context, opts ...GetActivitiesOption) (*AggregatedFeedResponse, error) { 17 | body, err := f.client.getActivities(ctx, f, opts...) 18 | if err != nil { 19 | return nil, err 20 | } 21 | var resp AggregatedFeedResponse 22 | if err := json.Unmarshal(body, &resp); err != nil { 23 | return nil, err 24 | } 25 | return &resp, nil 26 | } 27 | 28 | // GetActivitiesWithRanking returns the activities and groups for the given AggregatedFeed, 29 | // using the provided ranking method. 30 | func (f *AggregatedFeed) GetActivitiesWithRanking(ctx context.Context, ranking string, opts ...GetActivitiesOption) (*AggregatedFeedResponse, error) { 31 | return f.GetActivities(ctx, append(opts, WithActivitiesRanking(ranking))...) 32 | } 33 | 34 | // GetNextPageActivities returns the activities for the given AggregatedFeed at the "next" page 35 | // of a previous *AggregatedFeedResponse response, if any. 36 | func (f *AggregatedFeed) GetNextPageActivities(ctx context.Context, resp *AggregatedFeedResponse) (*AggregatedFeedResponse, error) { 37 | opts, err := resp.parseNext() 38 | if err != nil { 39 | return nil, err 40 | } 41 | return f.GetActivities(ctx, opts...) 42 | } 43 | 44 | // GetEnrichedActivities requests and retrieves the enriched activities and groups for the 45 | // aggregated feed. 46 | func (f *AggregatedFeed) GetEnrichedActivities(ctx context.Context, opts ...GetActivitiesOption) (*EnrichedAggregatedFeedResponse, error) { 47 | body, err := f.client.getEnrichedActivities(ctx, f, opts...) 48 | if err != nil { 49 | return nil, err 50 | } 51 | var resp EnrichedAggregatedFeedResponse 52 | if err := json.Unmarshal(body, &resp); err != nil { 53 | return nil, err 54 | } 55 | return &resp, nil 56 | } 57 | 58 | // GetNextPageEnrichedActivities returns the enriched activities for the given AggregatedFeed at the "next" page 59 | // of a previous *EnrichedAggregatedFeedResponse response, if any. 60 | func (f *AggregatedFeed) GetNextPageEnrichedActivities(ctx context.Context, resp *EnrichedAggregatedFeedResponse) (*EnrichedAggregatedFeedResponse, error) { 61 | opts, err := resp.parseNext() 62 | if err != nil { 63 | return nil, err 64 | } 65 | return f.GetEnrichedActivities(ctx, opts...) 66 | } 67 | 68 | // GetEnrichedActivitiesWithRanking returns the enriched activities and groups for the given AggregatedFeed, 69 | // using the provided ranking method. 70 | func (f *AggregatedFeed) GetEnrichedActivitiesWithRanking(ctx context.Context, ranking string, opts ...GetActivitiesOption) (*EnrichedAggregatedFeedResponse, error) { 71 | return f.GetEnrichedActivities(ctx, append(opts, WithActivitiesRanking(ranking))...) 72 | } 73 | -------------------------------------------------------------------------------- /aggregated_feed_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | stream "github.com/GetStream/stream-go2/v8" 13 | ) 14 | 15 | func TestAggregatedFeedGetActivities(t *testing.T) { 16 | ctx := context.Background() 17 | client, requester := newClient(t) 18 | aggregated, _ := newAggregatedFeedWithUserID(client, "123") 19 | testCases := []struct { 20 | opts []stream.GetActivitiesOption 21 | url string 22 | enrichedURL string 23 | }{ 24 | { 25 | url: "https://api.stream-io-api.com/api/v1.0/feed/aggregated/123/?api_key=key", 26 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/aggregated/123/?api_key=key", 27 | }, 28 | { 29 | opts: []stream.GetActivitiesOption{stream.WithActivitiesLimit(42)}, 30 | url: "https://api.stream-io-api.com/api/v1.0/feed/aggregated/123/?api_key=key&limit=42", 31 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/aggregated/123/?api_key=key&limit=42", 32 | }, 33 | { 34 | opts: []stream.GetActivitiesOption{stream.WithActivitiesLimit(42), stream.WithActivitiesOffset(11), stream.WithActivitiesIDGT("aabbcc")}, 35 | url: "https://api.stream-io-api.com/api/v1.0/feed/aggregated/123/?api_key=key&id_gt=aabbcc&limit=42&offset=11", 36 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/aggregated/123/?api_key=key&id_gt=aabbcc&limit=42&offset=11", 37 | }, 38 | } 39 | 40 | for _, tc := range testCases { 41 | _, err := aggregated.GetActivities(ctx, tc.opts...) 42 | assert.NoError(t, err) 43 | testRequest(t, requester.req, http.MethodGet, tc.url, "") 44 | 45 | _, err = aggregated.GetActivitiesWithRanking(ctx, "popularity", tc.opts...) 46 | testRequest(t, requester.req, http.MethodGet, fmt.Sprintf("%s&ranking=popularity", tc.url), "") 47 | assert.NoError(t, err) 48 | 49 | _, err = aggregated.GetEnrichedActivities(ctx, tc.opts...) 50 | assert.NoError(t, err) 51 | testRequest(t, requester.req, http.MethodGet, tc.enrichedURL, "") 52 | 53 | _, err = aggregated.GetEnrichedActivitiesWithRanking(ctx, "popularity", tc.opts...) 54 | testRequest(t, requester.req, http.MethodGet, fmt.Sprintf("%s&ranking=popularity", tc.enrichedURL), "") 55 | assert.NoError(t, err) 56 | } 57 | } 58 | 59 | func TestAggregatedFeedGetNextPageActivities(t *testing.T) { 60 | ctx := context.Background() 61 | client, requester := newClient(t) 62 | aggregated, _ := newAggregatedFeedWithUserID(client, "123") 63 | 64 | requester.resp = `{"next":"/api/v1.0/feed/aggregated/123/?id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25"}` 65 | resp, err := aggregated.GetActivities(ctx) 66 | require.NoError(t, err) 67 | _, err = aggregated.GetNextPageActivities(ctx, resp) 68 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/feed/aggregated/123/?api_key=key&id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25", "") 69 | require.NoError(t, err) 70 | 71 | requester.resp = `{"next":"/api/v1.0/enrich/feed/aggregated/123/?id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25"}` 72 | enrichedResp, err := aggregated.GetEnrichedActivities(ctx) 73 | require.NoError(t, err) 74 | _, err = aggregated.GetNextPageEnrichedActivities(ctx, enrichedResp) 75 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/enrich/feed/aggregated/123/?api_key=key&id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25", "") 76 | require.NoError(t, err) 77 | 78 | requester.resp = `{"next":123}` 79 | _, err = aggregated.GetActivities(ctx) 80 | require.Error(t, err) 81 | 82 | requester.resp = `{"next":"123"}` 83 | resp, err = aggregated.GetActivities(ctx) 84 | require.NoError(t, err) 85 | _, err = aggregated.GetNextPageActivities(ctx, resp) 86 | require.Error(t, err) 87 | 88 | requester.resp = `{"next":"?q=a%"}` 89 | resp, err = aggregated.GetActivities(ctx) 90 | require.NoError(t, err) 91 | _, err = aggregated.GetNextPageActivities(ctx, resp) 92 | require.Error(t, err) 93 | } 94 | -------------------------------------------------------------------------------- /analytics.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | // AnalyticsClient is a specialized client used to send and track 9 | // analytics events for enabled apps. 10 | type AnalyticsClient struct { 11 | client *Client 12 | } 13 | 14 | // TrackEngagement is used to send and track analytics EngagementEvents. 15 | func (c *AnalyticsClient) TrackEngagement(ctx context.Context, events ...EngagementEvent) (*BaseResponse, error) { 16 | endpoint := c.client.makeEndpoint("engagement/") 17 | data := map[string]any{ 18 | "content_list": events, 19 | } 20 | return decode(c.client.post(ctx, endpoint, data, c.client.authenticator.analyticsAuth)) 21 | } 22 | 23 | // TrackImpression is used to send and track analytics ImpressionEvents. 24 | func (c *AnalyticsClient) TrackImpression(ctx context.Context, eventsData ImpressionEventsData) (*BaseResponse, error) { 25 | endpoint := c.client.makeEndpoint("impression/") 26 | return decode(c.client.post(ctx, endpoint, eventsData, c.client.authenticator.analyticsAuth)) 27 | } 28 | 29 | // RedirectAndTrack is used to send and track analytics ImpressionEvents. It tracks 30 | // the events data (either EngagementEvents or ImpressionEvents) and redirects to the provided 31 | // URL string. 32 | func (c *AnalyticsClient) RedirectAndTrack(url string, events ...map[string]any) (string, error) { 33 | endpoint := c.client.makeEndpoint("redirect/") 34 | eventsData, err := json.Marshal(events) 35 | if err != nil { 36 | return "", err 37 | } 38 | endpoint.addQueryParam(makeRequestOption("events", string(eventsData))) 39 | endpoint.addQueryParam(makeRequestOption("url", url)) 40 | err = c.client.authenticator.signAnalyticsRedirectEndpoint(&endpoint) 41 | return endpoint.String(), err 42 | } 43 | -------------------------------------------------------------------------------- /analytics_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | stream "github.com/GetStream/stream-go2/v8" 13 | ) 14 | 15 | func TestAnalyticsTrackEngagement(t *testing.T) { 16 | ctx := context.Background() 17 | client, requester := newClient(t) 18 | analytics := client.Analytics() 19 | event1 := stream.EngagementEvent{}. 20 | WithLabel("click"). 21 | WithForeignID("abcdef"). 22 | WithUserData(stream.NewUserData().Int(12345).Alias("John Doe")). 23 | WithFeedID("timeline:123"). 24 | WithLocation("hawaii"). 25 | WithPosition(42). 26 | WithBoost(10) 27 | 28 | event2 := stream.EngagementEvent{}. 29 | WithLabel("share"). 30 | WithForeignID("aabbccdd"). 31 | WithUserData(stream.NewUserData().String("bob")). 32 | WithFeedID("timeline:123"). 33 | WithFeatures( 34 | stream.NewEventFeature("color", "red"), 35 | stream.NewEventFeature("size", "xxl"), 36 | ) 37 | 38 | _, err := analytics.TrackEngagement(ctx, event1, event2) 39 | require.NoError(t, err) 40 | expectedURL := "https://analytics.stream-io-api.com/analytics/v1.0/engagement/?api_key=key" 41 | expectedBody := `{"content_list":[{"boost":10,"content":"abcdef","feed_id":"timeline:123","label":"click","location":"hawaii","position":42,"user_data":{"alias":"John Doe","id":12345}},{"content":"aabbccdd","features":[{"group":"color","value":"red"},{"group":"size","value":"xxl"}],"feed_id":"timeline:123","label":"share","user_data":"bob"}]}` 42 | testRequest(t, requester.req, http.MethodPost, expectedURL, expectedBody) 43 | } 44 | 45 | func TestAnalyticsTrackImpression(t *testing.T) { 46 | ctx := context.Background() 47 | client, requester := newClient(t) 48 | analytics := client.Analytics() 49 | imp := stream.ImpressionEventsData{}. 50 | WithForeignIDs("a", "b", "c", "d"). 51 | WithUserData(stream.NewUserData().Int(123)). 52 | WithFeedID("timeline:123"). 53 | WithFeatures( 54 | stream.NewEventFeature("color", "red"), 55 | stream.NewEventFeature("size", "xxl"), 56 | ). 57 | WithLocation("hawaii"). 58 | WithPosition(42) 59 | 60 | _, err := analytics.TrackImpression(ctx, imp) 61 | require.NoError(t, err) 62 | expectedURL := "https://analytics.stream-io-api.com/analytics/v1.0/impression/?api_key=key" 63 | expectedBody := `{"content_list":["a","b","c","d"],"features":[{"group":"color","value":"red"},{"group":"size","value":"xxl"}],"feed_id":"timeline:123","location":"hawaii","position":42,"user_data":123}` 64 | testRequest(t, requester.req, http.MethodPost, expectedURL, expectedBody) 65 | } 66 | 67 | func TestAnalyticsRedirectAndTrack(t *testing.T) { 68 | client, _ := newClient(t) 69 | analytics := client.Analytics() 70 | event1 := stream.EngagementEvent{}. 71 | WithLabel("click"). 72 | WithForeignID("abcdef"). 73 | WithUserData(stream.NewUserData().Int(12345).Alias("John Doe")). 74 | WithFeedID("timeline:123"). 75 | WithLocation("hawaii"). 76 | WithPosition(42). 77 | WithBoost(10) 78 | event2 := stream.EngagementEvent{}. 79 | WithLabel("share"). 80 | WithForeignID("aabbccdd"). 81 | WithUserData(stream.NewUserData().String("bob")). 82 | WithFeedID("timeline:123"). 83 | WithFeatures( 84 | stream.NewEventFeature("color", "red"), 85 | stream.NewEventFeature("size", "xxl"), 86 | ) 87 | imp := stream.ImpressionEventsData{}. 88 | WithForeignIDs("a", "b", "c", "d"). 89 | WithUserData(stream.NewUserData().Int(123)). 90 | WithFeedID("timeline:123"). 91 | WithFeatures( 92 | stream.NewEventFeature("color", "red"), 93 | stream.NewEventFeature("size", "xxl"), 94 | ). 95 | WithLocation("hawaii"). 96 | WithPosition(42) 97 | 98 | link, err := analytics.RedirectAndTrack("foo.bar.baz", event1, event2, imp) 99 | require.NoError(t, err) 100 | query, err := url.ParseQuery(`api_key=key&authorization=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpb24iOiIqIiwicmVzb3VyY2UiOiJyZWRpcmVjdF9hbmRfdHJhY2siLCJ1c2VyX2lkIjoiKiJ9.A1vy9pFwLw5s6qn0chkhRcoy974A16a0lE-x5Vtxb-o&events=[{"boost":10,"content":"abcdef","feed_id":"timeline:123","label":"click","location":"hawaii","position":42,"user_data":{"alias":"John Doe","id":12345}},{"content":"aabbccdd","features":[{"group":"color","value":"red"},{"group":"size","value":"xxl"}],"feed_id":"timeline:123","label":"share","user_data":"bob"},{"content_list":["a","b","c","d"],"features":[{"group":"color","value":"red"},{"group":"size","value":"xxl"}],"feed_id":"timeline:123","location":"hawaii","position":42,"user_data":123}]&stream-auth-type=jwt&url=foo.bar.baz`) 101 | require.NoError(t, err) 102 | expected := "https://analytics.stream-io-api.com/analytics/v1.0/redirect/?" + query.Encode() 103 | assert.Equal(t, expected, link) 104 | } 105 | -------------------------------------------------------------------------------- /analytics_types.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import "time" 4 | 5 | // EventFeature is a single analytics event feature, a pair of group and 6 | // value strings. 7 | type EventFeature struct { 8 | Group string `json:"group"` 9 | Value string `json:"value"` 10 | } 11 | 12 | // NewEventFeature returns a new EventFeature from the given group and value 13 | // params. 14 | func NewEventFeature(group, value string) EventFeature { 15 | return EventFeature{ 16 | Group: group, 17 | Value: value, 18 | } 19 | } 20 | 21 | // UserData represents an analytics event user data field, which can either 22 | // be a single string/integer representing the user's ID, or a dictionary 23 | // made of an ID (string or integer) and a string alias. 24 | // For example NewUserData().Int(123).Alias("john") will result in a dictionary 25 | // like {"user_data":{"id": 123, "alias": "john"}}, while NewUserData().String("bob") will 26 | // result in {"user_data": "bob"}. 27 | type UserData struct { 28 | id any 29 | alias string 30 | } 31 | 32 | // NewUserData initializes an empty UserData type, which must be populated 33 | // using the String, Int, and/or Alias methods. 34 | func NewUserData() *UserData { 35 | return &UserData{} 36 | } 37 | 38 | // String sets the ID as the given string. 39 | func (d *UserData) String(id string) *UserData { 40 | d.id = id 41 | return d 42 | } 43 | 44 | // Int sets the ID as the given integer. 45 | func (d *UserData) Int(id int) *UserData { 46 | d.id = id 47 | return d 48 | } 49 | 50 | // Alias sets the alias as the given string. 51 | func (d *UserData) Alias(alias string) *UserData { 52 | d.alias = alias 53 | return d 54 | } 55 | 56 | func (d *UserData) value() any { 57 | if d.alias == "" { 58 | return d.id 59 | } 60 | return map[string]any{ 61 | "id": d.id, 62 | "alias": d.alias, 63 | } 64 | } 65 | 66 | // EngagementEvent represents an analytics engagement event. It must be populated 67 | // with the available methods, or custom data can be arbitrarily added to it 68 | // manually as key(string),value(any) pairs. 69 | type EngagementEvent map[string]any 70 | 71 | // WithLabel sets the event's label field to the given string. 72 | func (e EngagementEvent) WithLabel(label string) EngagementEvent { 73 | e["label"] = label 74 | return e 75 | } 76 | 77 | // WithUserData sets the event's user_data field to the given UserData's value. 78 | func (e EngagementEvent) WithUserData(userData *UserData) EngagementEvent { 79 | e["user_data"] = userData.value() 80 | return e 81 | } 82 | 83 | // WithForeignID sets the event's content field to the given foreign ID. If the 84 | // content payload must be an object, use the WithContent method. 85 | func (e EngagementEvent) WithForeignID(foreignID string) EngagementEvent { 86 | e["content"] = foreignID 87 | return e 88 | } 89 | 90 | // WithContent sets the event's content field to the given content map, and also 91 | // sets the foreign_id field of such object to the given foreign ID string. 92 | // If just the foreign ID is required to be sent, use the WithForeignID method. 93 | func (e EngagementEvent) WithContent(foreignID string, content map[string]any) EngagementEvent { 94 | if content != nil { 95 | content["foreign_id"] = foreignID 96 | } 97 | e["content"] = content 98 | return e 99 | } 100 | 101 | // WithFeedID sets the event's feed_id field to the given string. 102 | func (e EngagementEvent) WithFeedID(feedID string) EngagementEvent { 103 | e["feed_id"] = feedID 104 | return e 105 | } 106 | 107 | // WithLocation sets the event's location field to the given string. 108 | func (e EngagementEvent) WithLocation(location string) EngagementEvent { 109 | e["location"] = location 110 | return e 111 | } 112 | 113 | // WithPosition sets the event's position field to the given int. 114 | func (e EngagementEvent) WithPosition(position int) EngagementEvent { 115 | e["position"] = position 116 | return e 117 | } 118 | 119 | // WithFeatures sets the event's features field to the given list of EventFeatures. 120 | func (e EngagementEvent) WithFeatures(features ...EventFeature) EngagementEvent { 121 | e["features"] = features 122 | return e 123 | } 124 | 125 | // WithBoost sets the event's boost field to the given int. 126 | func (e EngagementEvent) WithBoost(boost int) EngagementEvent { 127 | e["boost"] = boost 128 | return e 129 | } 130 | 131 | // WithTrackedAt sets the event's tracked_at field to the given time.Time. 132 | func (e EngagementEvent) WithTrackedAt(trackedAt time.Time) EngagementEvent { 133 | e["tracked_at"] = trackedAt.Format(time.RFC3339) 134 | return e 135 | } 136 | 137 | // ImpressionEventsData represents the payload of an arbitrary number of impression events. 138 | // It must be populated with the available methods, or custom data can be arbitrarily 139 | // added to it manually as key(string),value(any) pairs. 140 | type ImpressionEventsData map[string]any 141 | 142 | // WithForeignIDs sets the content_list field to the given list of strings. 143 | func (d ImpressionEventsData) WithForeignIDs(foreignIDs ...string) ImpressionEventsData { 144 | d["content_list"] = foreignIDs 145 | return d 146 | } 147 | 148 | // AddForeignIDs adds the given foreign ID strings to the content_list field, creating 149 | // it if it doesn't exist. 150 | func (d ImpressionEventsData) AddForeignIDs(foreignIDs ...string) ImpressionEventsData { 151 | list, ok := d["content_list"].([]string) 152 | if !ok { 153 | return d.WithForeignIDs(foreignIDs...) 154 | } 155 | return d.WithForeignIDs(append(list, foreignIDs...)...) 156 | } 157 | 158 | // WithUserData sets the user_data field to the given UserData value. 159 | func (d ImpressionEventsData) WithUserData(userData *UserData) ImpressionEventsData { 160 | d["user_data"] = userData.value() 161 | return d 162 | } 163 | 164 | // WithFeedID sets the feed_id field to the given string. 165 | func (d ImpressionEventsData) WithFeedID(feedID string) ImpressionEventsData { 166 | d["feed_id"] = feedID 167 | return d 168 | } 169 | 170 | // WithLocation sets the location field to the given string. 171 | func (d ImpressionEventsData) WithLocation(location string) ImpressionEventsData { 172 | d["location"] = location 173 | return d 174 | } 175 | 176 | // WithPosition sets the position field to the given int. 177 | func (d ImpressionEventsData) WithPosition(position int) ImpressionEventsData { 178 | d["position"] = position 179 | return d 180 | } 181 | 182 | // WithFeatures sets the features field to the given list of EventFeatures. 183 | func (d ImpressionEventsData) WithFeatures(features ...EventFeature) ImpressionEventsData { 184 | d["features"] = features 185 | return d 186 | } 187 | 188 | // WithTrackedAt sets the tracked_at field to the given time.Time. 189 | func (d ImpressionEventsData) WithTrackedAt(trackedAt time.Time) ImpressionEventsData { 190 | d["tracked_at"] = trackedAt.Format(time.RFC3339) 191 | return d 192 | } 193 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | STREAM MARK 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /audit_logs.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | type AuditLogsClient struct { 10 | client *Client 11 | } 12 | 13 | type AuditLog struct { 14 | EntityType string `json:"entity_type"` 15 | EntityID string `json:"entity_id"` 16 | Action string `json:"action"` 17 | UserID string `json:"user_id"` 18 | Custom map[string]any `json:"custom"` 19 | CreatedAt time.Time `json:"created_at"` 20 | } 21 | 22 | type QueryAuditLogsResponse struct { 23 | AuditLogs []AuditLog `json:"audit_logs"` 24 | Next string `json:"next"` 25 | Prev string `json:"prev"` 26 | response 27 | } 28 | 29 | type QueryAuditLogsFilters struct { 30 | EntityType string 31 | EntityID string 32 | UserID string 33 | } 34 | 35 | type QueryAuditLogsPager struct { 36 | Next string 37 | Prev string 38 | Limit int 39 | } 40 | 41 | func (c *AuditLogsClient) QueryAuditLogs(ctx context.Context, filters QueryAuditLogsFilters, pager QueryAuditLogsPager) (*QueryAuditLogsResponse, error) { 42 | endpoint := c.client.makeEndpoint("audit_logs/") 43 | if filters.EntityType != "" && filters.EntityID != "" { 44 | endpoint.addQueryParam(makeRequestOption("entity_type", filters.EntityType)) 45 | endpoint.addQueryParam(makeRequestOption("entity_id", filters.EntityID)) 46 | } 47 | if filters.UserID != "" { 48 | endpoint.addQueryParam(makeRequestOption("user_id", filters.UserID)) 49 | } 50 | if pager.Next != "" { 51 | endpoint.addQueryParam(makeRequestOption("next", pager.Next)) 52 | } 53 | if pager.Prev != "" { 54 | endpoint.addQueryParam(makeRequestOption("prev", pager.Prev)) 55 | } 56 | if pager.Limit > 0 { 57 | endpoint.addQueryParam(makeRequestOption("limit", pager.Limit)) 58 | } 59 | body, err := c.client.get(ctx, endpoint, nil, c.client.authenticator.auditLogsAuth) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | var resp QueryAuditLogsResponse 65 | if err := json.Unmarshal(body, &resp); err != nil { 66 | return nil, err 67 | } 68 | 69 | return &resp, nil 70 | } 71 | -------------------------------------------------------------------------------- /audit_logs_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | stream "github.com/GetStream/stream-go2/v8" 14 | ) 15 | 16 | func TestQueryAuditLogs(t *testing.T) { 17 | ctx := context.Background() 18 | client, requester := newClient(t) 19 | 20 | // Test with all filters and pager options 21 | filters := stream.QueryAuditLogsFilters{ 22 | EntityType: "feed", 23 | EntityID: "123", 24 | UserID: "user-42", 25 | } 26 | pager := stream.QueryAuditLogsPager{ 27 | Next: "next-token", 28 | Prev: "prev-token", 29 | Limit: 25, 30 | } 31 | 32 | // Set mock response 33 | now := time.Now() 34 | mockResp := struct { 35 | AuditLogs []stream.AuditLog `json:"audit_logs"` 36 | Next string `json:"next"` 37 | Prev string `json:"prev"` 38 | }{ 39 | AuditLogs: []stream.AuditLog{ 40 | { 41 | EntityType: "feed", 42 | EntityID: "123", 43 | Action: "create", 44 | UserID: "user-42", 45 | Custom: map[string]any{"key": "value"}, 46 | CreatedAt: now, 47 | }, 48 | }, 49 | Next: "next-page-token", 50 | Prev: "prev-page-token", 51 | } 52 | respBytes, err := json.Marshal(mockResp) 53 | require.NoError(t, err) 54 | requester.resp = string(respBytes) 55 | 56 | // Call the function 57 | resp, err := client.AuditLogs().QueryAuditLogs(ctx, filters, pager) 58 | require.NoError(t, err) 59 | 60 | // Verify request 61 | testRequest( 62 | t, 63 | requester.req, 64 | http.MethodGet, 65 | "https://api.stream-io-api.com/api/v1.0/audit_logs/?api_key=key&entity_id=123&entity_type=feed&limit=25&next=next-token&prev=prev-token&user_id=user-42", 66 | "", 67 | ) 68 | 69 | // Verify response 70 | assert.Len(t, resp.AuditLogs, 1) 71 | assert.Equal(t, "feed", resp.AuditLogs[0].EntityType) 72 | assert.Equal(t, "123", resp.AuditLogs[0].EntityID) 73 | assert.Equal(t, "create", resp.AuditLogs[0].Action) 74 | assert.Equal(t, "user-42", resp.AuditLogs[0].UserID) 75 | assert.Equal(t, "value", resp.AuditLogs[0].Custom["key"]) 76 | assert.Equal(t, now.Truncate(time.Second).UTC(), resp.AuditLogs[0].CreatedAt.Truncate(time.Second).UTC()) 77 | assert.Equal(t, "next-page-token", resp.Next) 78 | assert.Equal(t, "prev-page-token", resp.Prev) 79 | } 80 | 81 | func TestQueryAuditLogsWithMinimalParams(t *testing.T) { 82 | ctx := context.Background() 83 | client, requester := newClient(t) 84 | 85 | // Test with minimal filters and pager options 86 | filters := stream.QueryAuditLogsFilters{} 87 | pager := stream.QueryAuditLogsPager{} 88 | 89 | // Set mock response 90 | mockResp := struct { 91 | AuditLogs []stream.AuditLog `json:"audit_logs"` 92 | Next string `json:"next"` 93 | Prev string `json:"prev"` 94 | }{ 95 | AuditLogs: []stream.AuditLog{}, 96 | Next: "", 97 | Prev: "", 98 | } 99 | respBytes, err := json.Marshal(mockResp) 100 | require.NoError(t, err) 101 | requester.resp = string(respBytes) 102 | 103 | // Call the function 104 | resp, err := client.AuditLogs().QueryAuditLogs(ctx, filters, pager) 105 | require.NoError(t, err) 106 | 107 | // Verify request 108 | testRequest( 109 | t, 110 | requester.req, 111 | http.MethodGet, 112 | "https://api.stream-io-api.com/api/v1.0/audit_logs/?api_key=key", 113 | "", 114 | ) 115 | 116 | // Verify response 117 | assert.Empty(t, resp.AuditLogs) 118 | assert.Empty(t, resp.Next) 119 | assert.Empty(t, resp.Prev) 120 | } 121 | -------------------------------------------------------------------------------- /authenticator.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/golang-jwt/jwt/v4" 8 | ) 9 | 10 | type authFunc func(*http.Request) error 11 | 12 | type resource string 13 | 14 | const ( 15 | resFollower resource = "follower" 16 | resActivities resource = "activities" 17 | resFeed resource = "feed" 18 | resFeedTargets resource = "feed_targets" 19 | resCollections resource = "collections" 20 | resUsers resource = "users" 21 | resReactions resource = "reactions" 22 | resPersonalization resource = "personalization" 23 | resAnalytics resource = "analytics" 24 | resAnalyticsRedirect resource = "redirect_and_track" 25 | resModeration resource = "moderation" 26 | resAuditLogs resource = "audit_logs" 27 | ) 28 | 29 | type action string 30 | 31 | const ( 32 | actionRead action = "read" 33 | actionWrite action = "write" 34 | actionDelete action = "delete" 35 | ) 36 | 37 | var actions = map[string]action{ 38 | http.MethodGet: actionRead, 39 | http.MethodOptions: actionRead, 40 | http.MethodHead: actionRead, 41 | http.MethodPost: actionWrite, 42 | http.MethodPut: actionWrite, 43 | http.MethodPatch: actionWrite, 44 | http.MethodDelete: actionDelete, 45 | } 46 | 47 | type authenticator struct { 48 | secret string 49 | } 50 | 51 | func (a authenticator) feedID(feed Feed) string { 52 | if feed == nil { 53 | return "*" 54 | } 55 | return fmt.Sprintf("%s%s", feed.Slug(), feed.UserID()) 56 | } 57 | 58 | func (a authenticator) feedAuth(resource resource, feed Feed) authFunc { 59 | return func(req *http.Request) error { 60 | var feedID string 61 | if feed != nil { 62 | feedID = a.feedID(feed) 63 | } else { 64 | feedID = "*" 65 | } 66 | return a.jwtSignRequest(req, a.jwtFeedClaims(resource, actions[req.Method], feedID)) 67 | } 68 | } 69 | 70 | func (a authenticator) collectionsAuth(req *http.Request) error { 71 | claims := jwt.MapClaims{ 72 | "action": "*", 73 | "feed_id": "*", 74 | "resource": resCollections, 75 | } 76 | return a.jwtSignRequest(req, claims) 77 | } 78 | 79 | func (a authenticator) usersAuth(req *http.Request) error { 80 | claims := jwt.MapClaims{ 81 | "action": "*", 82 | "feed_id": "*", 83 | "resource": resUsers, 84 | } 85 | return a.jwtSignRequest(req, claims) 86 | } 87 | 88 | func (a authenticator) reactionsAuth(req *http.Request) error { 89 | claims := jwt.MapClaims{ 90 | "action": "*", 91 | "feed_id": "*", 92 | "resource": resReactions, 93 | } 94 | return a.jwtSignRequest(req, claims) 95 | } 96 | 97 | func (a authenticator) personalizationAuth(req *http.Request) error { 98 | claims := jwt.MapClaims{ 99 | "action": "*", 100 | "user_id": "*", 101 | "feed_id": "*", 102 | "resource": resPersonalization, 103 | } 104 | return a.jwtSignRequest(req, claims) 105 | } 106 | 107 | func (a authenticator) analyticsAuth(req *http.Request) error { 108 | claims := jwt.MapClaims{ 109 | "action": "*", 110 | "user_id": "*", 111 | "resource": resAnalytics, 112 | } 113 | return a.jwtSignRequest(req, claims) 114 | } 115 | 116 | func (a authenticator) moderationAuth(req *http.Request) error { 117 | claims := jwt.MapClaims{ 118 | "action": "*", 119 | "feed_id": "*", 120 | "resource": resModeration, 121 | } 122 | return a.jwtSignRequest(req, claims) 123 | } 124 | 125 | func (a authenticator) auditLogsAuth(req *http.Request) error { 126 | claims := jwt.MapClaims{ 127 | "action": "*", 128 | "feed_id": "*", 129 | "resource": resAuditLogs, 130 | } 131 | return a.jwtSignRequest(req, claims) 132 | } 133 | 134 | func (a authenticator) signAnalyticsRedirectEndpoint(endpoint *endpoint) error { 135 | claims := jwt.MapClaims{ 136 | "action": "*", 137 | "user_id": "*", 138 | "resource": resAnalyticsRedirect, 139 | } 140 | signature, err := a.jwtSignatureFromClaims(claims) 141 | if err != nil { 142 | return err 143 | } 144 | endpoint.addQueryParam(makeRequestOption("stream-auth-type", "jwt")) 145 | endpoint.addQueryParam(makeRequestOption("authorization", signature)) 146 | return nil 147 | } 148 | 149 | func (a authenticator) jwtSignatureFromClaims(claims jwt.MapClaims) (string, error) { 150 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 151 | return token.SignedString([]byte(a.secret)) 152 | } 153 | 154 | func (a authenticator) jwtFeedClaims(resource resource, action action, feedID string) jwt.MapClaims { 155 | return jwt.MapClaims{ 156 | "resource": resource, 157 | "action": action, 158 | "feed_id": feedID, 159 | } 160 | } 161 | 162 | func (a authenticator) jwtSignRequest(req *http.Request, claims jwt.MapClaims) error { 163 | auth, err := a.jwtSignatureFromClaims(claims) 164 | if err != nil { 165 | return fmt.Errorf("cannot make auth: %w", err) 166 | } 167 | req.Header.Add("Stream-Auth-Type", "jwt") 168 | req.Header.Add("Authorization", auth) 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /authenticator_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestFeedAuth(t *testing.T) { 12 | a := authenticator{secret: "something very secret"} 13 | req, err := http.NewRequest(http.MethodPost, "", http.NoBody) 14 | require.NoError(t, err) 15 | 16 | err = a.feedAuth(resFeed, nil)(req) 17 | assert.NoError(t, err) 18 | assert.Equal(t, "jwt", req.Header.Get("stream-auth-type")) 19 | expectedAuth := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpb24iOiJ3cml0ZSIsImZlZWRfaWQiOiIqIiwicmVzb3VyY2UiOiJmZWVkIn0.LnWdqnKryMuXEX3p8HepCBRVGfvhdzINmA2jU1j3TUA" 20 | assert.Equal(t, expectedAuth, req.Header.Get("authorization")) 21 | } 22 | -------------------------------------------------------------------------------- /client_internal_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestConfig(t *testing.T) { 19 | testCases := []struct { 20 | key string 21 | secret string 22 | shouldError bool 23 | opts []ClientOption 24 | expectedRegion string 25 | expectedVersion string 26 | }{ 27 | { 28 | shouldError: true, 29 | }, 30 | { 31 | key: "k", secret: "s", 32 | expectedRegion: "", 33 | expectedVersion: "", 34 | }, 35 | { 36 | key: "k", secret: "s", 37 | opts: []ClientOption{WithAPIRegion("test")}, 38 | expectedRegion: "test", 39 | expectedVersion: "", 40 | }, 41 | { 42 | key: "k", secret: "s", 43 | opts: []ClientOption{WithAPIVersion("test")}, 44 | expectedRegion: "", 45 | expectedVersion: "test", 46 | }, 47 | { 48 | key: "k", secret: "s", 49 | opts: []ClientOption{WithAPIRegion("test"), WithAPIVersion("more")}, 50 | expectedRegion: "test", 51 | expectedVersion: "more", 52 | }, 53 | } 54 | for _, tc := range testCases { 55 | c, err := New(tc.key, tc.secret, tc.opts...) 56 | if tc.shouldError { 57 | assert.Error(t, err) 58 | continue 59 | } 60 | assert.NoError(t, err) 61 | assert.Equal(t, tc.expectedRegion, c.urlBuilder.(apiURLBuilder).region) 62 | assert.Equal(t, tc.expectedVersion, c.urlBuilder.(apiURLBuilder).version) 63 | } 64 | } 65 | 66 | func Test_makeEndpoint(t *testing.T) { 67 | prev := os.Getenv("STREAM_URL") 68 | defer os.Setenv("STREAM_URL", prev) 69 | 70 | testCases := []struct { 71 | urlBuilder apiURLBuilder 72 | format string 73 | env string 74 | args []any 75 | expected string 76 | }{ 77 | { 78 | urlBuilder: apiURLBuilder{}, 79 | format: "test-%d-%s", 80 | args: []any{42, "asd"}, 81 | expected: "https://api.stream-io-api.com/api/v1.0/test-42-asd?api_key=test", 82 | }, 83 | { 84 | urlBuilder: apiURLBuilder{}, 85 | env: "http://localhost:8000", 86 | format: "test-%d-%s", 87 | args: []any{42, "asd"}, 88 | expected: "http://localhost:8000/api/v1.0/test-42-asd?api_key=test", 89 | }, 90 | { 91 | urlBuilder: apiURLBuilder{addr: "http://localhost:1234"}, 92 | expected: "http://localhost:1234/api/v1.0/?api_key=test", 93 | }, 94 | } 95 | 96 | for _, tc := range testCases { 97 | os.Setenv("STREAM_URL", tc.env) 98 | c := &Client{urlBuilder: tc.urlBuilder, key: "test"} 99 | assert.Equal(t, tc.expected, c.makeEndpoint(tc.format, tc.args...).String()) 100 | } 101 | } 102 | 103 | func TestNewFromEnv(t *testing.T) { 104 | reset, err := resetEnv(map[string]string{ 105 | "STREAM_API_KEY": "", 106 | "STREAM_API_SECRET": "", 107 | "STREAM_API_REGION": "", 108 | "STREAM_API_VERSION": "", 109 | }) 110 | require.NoError(t, err) 111 | defer reset() 112 | 113 | _, err = NewFromEnv() 114 | require.Error(t, err) 115 | 116 | os.Setenv("STREAM_API_KEY", "foo") 117 | os.Setenv("STREAM_API_SECRET", "bar") 118 | 119 | client, err := NewFromEnv(WithTimeout(6 * time.Second)) 120 | require.NoError(t, err) 121 | assert.Equal(t, "foo", client.key) 122 | assert.Equal(t, "bar", client.authenticator.secret) 123 | assert.Equal(t, 6*time.Second, client.timeout) 124 | 125 | os.Setenv("STREAM_API_REGION", "baz") 126 | client, err = NewFromEnv() 127 | require.NoError(t, err) 128 | assert.Equal(t, "baz", client.urlBuilder.(apiURLBuilder).region) 129 | 130 | os.Setenv("STREAM_API_VERSION", "qux") 131 | client, err = NewFromEnv() 132 | require.NoError(t, err) 133 | assert.Equal(t, "qux", client.urlBuilder.(apiURLBuilder).version) 134 | } 135 | 136 | type badReader struct{} 137 | 138 | func (badReader) Read([]byte) (int, error) { return 0, fmt.Errorf("boom") } 139 | 140 | func Test_makeStreamError(t *testing.T) { 141 | testCases := []struct { 142 | body io.Reader 143 | expected error 144 | apiErr APIError 145 | }{ 146 | { 147 | body: nil, 148 | expected: fmt.Errorf("invalid body"), 149 | }, 150 | { 151 | body: badReader{}, 152 | expected: fmt.Errorf("boom"), 153 | }, 154 | { 155 | body: strings.NewReader(`{{`), 156 | expected: fmt.Errorf("unexpected error (status code 123)"), 157 | }, 158 | { 159 | body: strings.NewReader(`{"code":"A"}`), 160 | expected: fmt.Errorf("unexpected error (status code 123)"), 161 | }, 162 | { 163 | body: strings.NewReader(`{"code":1, "detail":"test", "duration": "1m2s", "exception": "boom", "status_code": 456, "exception_fields": {"foo":["bar"]}}`), 164 | expected: fmt.Errorf("test"), 165 | apiErr: APIError{ 166 | Code: 1, 167 | Detail: "test", 168 | Duration: Duration{time.Minute + time.Second*2}, 169 | Exception: "boom", 170 | StatusCode: 123, 171 | ExceptionFields: map[string][]any{ 172 | "foo": {"bar"}, 173 | }, 174 | }, 175 | }, 176 | } 177 | for _, tc := range testCases { 178 | err := (&Client{}).makeStreamError(123, nil, tc.body) 179 | assert.Equal(t, tc.expected.Error(), err.Error()) 180 | if tc.apiErr.Code != 0 { 181 | assert.Equal(t, tc.apiErr, err) 182 | } 183 | } 184 | } 185 | 186 | type requester struct { 187 | code int 188 | body io.ReadCloser 189 | err error 190 | } 191 | 192 | func (r requester) Do(*http.Request) (*http.Response, error) { 193 | resp := &http.Response{ 194 | StatusCode: r.code, 195 | Body: r.body, 196 | } 197 | return resp, r.err 198 | } 199 | 200 | func Test_requestErrors(t *testing.T) { 201 | ctx := context.Background() 202 | testCases := []struct { 203 | data any 204 | method string 205 | authFn authFunc 206 | expected error 207 | requester Requester 208 | }{ 209 | { 210 | data: make(chan int), 211 | expected: fmt.Errorf("cannot marshal request: json: unsupported type: chan int"), 212 | }, 213 | { 214 | data: 42, 215 | authFn: func(*http.Request) error { return fmt.Errorf("boom") }, 216 | expected: fmt.Errorf("boom"), 217 | }, 218 | { 219 | data: 42, 220 | method: "Ω", 221 | expected: fmt.Errorf(`cannot create request: net/http: invalid method "Ω"`), 222 | }, 223 | { 224 | data: 42, 225 | authFn: func(*http.Request) error { return nil }, 226 | requester: &requester{err: fmt.Errorf("boom")}, 227 | expected: fmt.Errorf("cannot perform request: boom"), 228 | }, 229 | { 230 | data: 42, 231 | authFn: func(*http.Request) error { return nil }, 232 | requester: &requester{code: 400, body: io.NopCloser(strings.NewReader(`{"detail":"boom"}`))}, 233 | expected: fmt.Errorf("boom"), 234 | }, 235 | { 236 | data: 42, 237 | authFn: func(*http.Request) error { return nil }, 238 | requester: &requester{code: 200, body: io.NopCloser(badReader{})}, 239 | expected: fmt.Errorf("cannot read response: boom"), 240 | }, 241 | } 242 | 243 | for _, tc := range testCases { 244 | c := &Client{requester: tc.requester} 245 | _, err := c.request(ctx, tc.method, endpoint{url: &url.URL{}, query: url.Values{}}, tc.data, tc.authFn) 246 | require.Error(t, err) 247 | assert.Equal(t, tc.expected.Error(), err.Error()) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "github.com/golang-jwt/jwt/v4" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | stream "github.com/GetStream/stream-go2/v8" 15 | ) 16 | 17 | func TestHeaders(t *testing.T) { 18 | ctx := context.Background() 19 | client, requester := newClient(t) 20 | feed, err := client.FlatFeed("user", "123") 21 | require.NoError(t, err) 22 | 23 | _, err = feed.GetActivities(ctx) 24 | require.NoError(t, err) 25 | assert.Equal(t, "application/json", requester.req.Header.Get("content-type")) 26 | assert.Regexp(t, "^stream-go2-client-v[0-9\\.]+$", requester.req.Header.Get("x-stream-client")) 27 | } 28 | 29 | func TestAddToMany(t *testing.T) { 30 | var ( 31 | client, requester = newClient(t) 32 | ctx = context.Background() 33 | activity = stream.Activity{Actor: "bob", Verb: "like", Object: "cake"} 34 | flat, _ = newFlatFeedWithUserID(client, "123") 35 | aggregated, _ = newAggregatedFeedWithUserID(client, "123") 36 | ) 37 | 38 | err := client.AddToMany(ctx, activity, flat, aggregated) 39 | require.NoError(t, err) 40 | body := `{"activity":{"actor":"bob","object":"cake","verb":"like"},"feeds":["flat:123","aggregated:123"]}` 41 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed/add_to_many/?api_key=key", body) 42 | } 43 | 44 | func TestFollowMany(t *testing.T) { 45 | var ( 46 | client, requester = newClient(t) 47 | ctx = context.Background() 48 | relationships = make([]stream.FollowRelationship, 3) 49 | flat, _ = newFlatFeedWithUserID(client, "123") 50 | ) 51 | 52 | for i := range relationships { 53 | other, _ := newAggregatedFeedWithUserID(client, strconv.Itoa(i)) 54 | relationships[i] = stream.NewFollowRelationship(other, flat) 55 | } 56 | 57 | err := client.FollowMany(ctx, relationships) 58 | require.NoError(t, err) 59 | body := `[{"source":"aggregated:0","target":"flat:123"},{"source":"aggregated:1","target":"flat:123"},{"source":"aggregated:2","target":"flat:123"}]` 60 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/follow_many/?api_key=key", body) 61 | 62 | err = client.FollowMany(ctx, relationships, stream.WithFollowManyActivityCopyLimit(500)) 63 | require.NoError(t, err) 64 | body = `[{"source":"aggregated:0","target":"flat:123"},{"source":"aggregated:1","target":"flat:123"},{"source":"aggregated:2","target":"flat:123"}]` 65 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/follow_many/?activity_copy_limit=500&api_key=key", body) 66 | } 67 | 68 | func TestFollowManyActivityCopyLimit(t *testing.T) { 69 | var ( 70 | client, requester = newClient(t) 71 | ctx = context.Background() 72 | relationships = make([]stream.FollowRelationship, 3) 73 | flat, _ = newFlatFeedWithUserID(client, "123") 74 | ) 75 | 76 | for i := range relationships { 77 | other, _ := newAggregatedFeedWithUserID(client, strconv.Itoa(i)) 78 | relationships[i] = stream.NewFollowRelationship(other, flat, stream.WithFollowRelationshipActivityCopyLimit(i)) 79 | } 80 | 81 | err := client.FollowMany(ctx, relationships) 82 | require.NoError(t, err) 83 | body := `[{"source":"aggregated:0","target":"flat:123","activity_copy_limit":0},{"source":"aggregated:1","target":"flat:123","activity_copy_limit":1},{"source":"aggregated:2","target":"flat:123","activity_copy_limit":2}]` 84 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/follow_many/?api_key=key", body) 85 | 86 | err = client.FollowMany(ctx, relationships, stream.WithFollowManyActivityCopyLimit(123)) 87 | require.NoError(t, err) 88 | body = `[{"source":"aggregated:0","target":"flat:123","activity_copy_limit":0},{"source":"aggregated:1","target":"flat:123","activity_copy_limit":1},{"source":"aggregated:2","target":"flat:123","activity_copy_limit":2}]` 89 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/follow_many/?activity_copy_limit=123&api_key=key", body) 90 | } 91 | 92 | func TestUnfollowMany(t *testing.T) { 93 | var ( 94 | client, requester = newClient(t) 95 | ctx = context.Background() 96 | relationships = make([]stream.UnfollowRelationship, 3) 97 | ) 98 | for i := range relationships { 99 | var options []stream.UnfollowRelationshipOption 100 | if i%2 == 0 { 101 | options = append(options, stream.WithUnfollowRelationshipKeepHistory()) 102 | } 103 | src, err := client.FlatFeed("src", strconv.Itoa(i)) 104 | require.NoError(t, err) 105 | tgt, err := client.FlatFeed("tgt", strconv.Itoa(i)) 106 | require.NoError(t, err) 107 | 108 | relationships[i] = stream.NewUnfollowRelationship(src, tgt, options...) 109 | } 110 | 111 | err := client.UnfollowMany(ctx, relationships) 112 | require.NoError(t, err) 113 | body := `[{"source":"src:0","target":"tgt:0","keep_history":true},{"source":"src:1","target":"tgt:1","keep_history":false},{"source":"src:2","target":"tgt:2","keep_history":true}]` 114 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/unfollow_many/?api_key=key", body) 115 | } 116 | 117 | func TestGetActivities(t *testing.T) { 118 | ctx := context.Background() 119 | client, requester := newClient(t) 120 | _, err := client.GetActivitiesByID(ctx, "foo", "bar", "baz") 121 | require.NoError(t, err) 122 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/activities/?api_key=key&ids=foo%2Cbar%2Cbaz", "") 123 | _, err = client.GetActivitiesByForeignID( 124 | ctx, 125 | stream.NewForeignIDTimePair("foo", stream.Time{}), 126 | stream.NewForeignIDTimePair("bar", stream.Time{Time: time.Time{}.Add(time.Second)}), 127 | ) 128 | require.NoError(t, err) 129 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/activities/?api_key=key&foreign_ids=foo%2Cbar×tamps=0001-01-01T00%3A00%3A00%2C0001-01-01T00%3A00%3A01", "") 130 | } 131 | 132 | func TestGetEnrichedActivities(t *testing.T) { 133 | ctx := context.Background() 134 | client, requester := newClient(t) 135 | _, err := client.GetEnrichedActivitiesByID(ctx, []string{"foo", "bar", "baz"}, stream.WithEnrichReactionCounts()) 136 | require.NoError(t, err) 137 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/enrich/activities/?api_key=key&ids=foo%2Cbar%2Cbaz&withReactionCounts=true", "") 138 | _, err = client.GetEnrichedActivitiesByForeignID( 139 | ctx, 140 | []stream.ForeignIDTimePair{ 141 | stream.NewForeignIDTimePair("foo", stream.Time{}), 142 | stream.NewForeignIDTimePair("bar", stream.Time{Time: time.Time{}.Add(time.Second)}), 143 | }, 144 | stream.WithEnrichReactionCounts(), 145 | ) 146 | require.NoError(t, err) 147 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/enrich/activities/?api_key=key&foreign_ids=foo%2Cbar×tamps=0001-01-01T00%3A00%3A00%2C0001-01-01T00%3A00%3A01&withReactionCounts=true", "") 148 | } 149 | 150 | func TestGetReactions(t *testing.T) { 151 | ctx := context.Background() 152 | client, requester := newClient(t) 153 | _, err := client.GetReactions(ctx, []string{"foo", "bar", "baz"}) 154 | require.NoError(t, err) 155 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/reaction/get_many/?api_key=key&ids=foo%2Cbar%2Cbaz", "") 156 | } 157 | 158 | func TestGetReactionsIncludeDeleted(t *testing.T) { 159 | ctx := context.Background() 160 | client, requester := newClient(t) 161 | _, err := client.GetReactions(ctx, []string{"foo", "bar", "baz"}, stream.WithReactionsIncludeDeleted()) 162 | require.NoError(t, err) 163 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/reaction/get_many/?api_key=key&ids=foo%2Cbar%2Cbaz&include_deleted=true", "") 164 | } 165 | 166 | func TestUpdateActivityByID(t *testing.T) { 167 | ctx := context.Background() 168 | client, requester := newClient(t) 169 | 170 | _, err := client.UpdateActivityByID(ctx, "abcdef", map[string]any{"foo.bar": "baz", "popularity": 42, "color": map[string]any{"hex": "FF0000", "rgb": "255,0,0"}}, []string{"a", "b", "c"}) 171 | require.NoError(t, err) 172 | body := `{"id":"abcdef","set":{"color":{"hex":"FF0000","rgb":"255,0,0"},"foo.bar":"baz","popularity":42},"unset":["a","b","c"]}` 173 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 174 | 175 | _, err = client.UpdateActivityByID(ctx, "abcdef", map[string]any{"foo.bar": "baz", "popularity": 42, "color": map[string]any{"hex": "FF0000", "rgb": "255,0,0"}}, nil) 176 | require.NoError(t, err) 177 | body = `{"id":"abcdef","set":{"color":{"hex":"FF0000","rgb":"255,0,0"},"foo.bar":"baz","popularity":42}}` 178 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 179 | 180 | _, err = client.UpdateActivityByID(ctx, "abcdef", nil, []string{"a", "b", "c"}) 181 | require.NoError(t, err) 182 | body = `{"id":"abcdef","unset":["a","b","c"]}` 183 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 184 | } 185 | 186 | func TestPartialUpdateActivities(t *testing.T) { 187 | ctx := context.Background() 188 | client, requester := newClient(t) 189 | 190 | _, err := client.PartialUpdateActivities( 191 | ctx, 192 | stream.NewUpdateActivityRequestByID( 193 | "abcdef", 194 | map[string]any{"foo.bar": "baz"}, 195 | []string{"qux", "tty"}, 196 | ), 197 | stream.NewUpdateActivityRequestByID( 198 | "ghijkl", 199 | map[string]any{"foo.bar": "baz"}, 200 | []string{"quux", "ttl"}, 201 | ), 202 | ) 203 | require.NoError(t, err) 204 | body := `{"changes":[{"id":"abcdef","set":{"foo.bar":"baz"},"unset":["qux","tty"]},{"id":"ghijkl","set":{"foo.bar":"baz"},"unset":["quux","ttl"]}]}` 205 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 206 | 207 | tt, _ := time.Parse(stream.TimeLayout, "2006-01-02T15:04:05.999999") 208 | _, err = client.PartialUpdateActivities( 209 | ctx, 210 | stream.NewUpdateActivityRequestByForeignID( 211 | "abcdef:123", 212 | stream.Time{Time: tt}, 213 | map[string]any{"foo.bar": "baz"}, 214 | nil, 215 | ), 216 | stream.NewUpdateActivityRequestByForeignID( 217 | "ghijkl:1234", 218 | stream.Time{Time: tt}, 219 | nil, 220 | []string{"quux", "ttl"}, 221 | ), 222 | ) 223 | require.NoError(t, err) 224 | body = `{"changes":[{"foreign_id":"abcdef:123","time":"2006-01-02T15:04:05.999999","set":{"foo.bar":"baz"}},{"foreign_id":"ghijkl:1234","time":"2006-01-02T15:04:05.999999","unset":["quux","ttl"]}]}` 225 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 226 | } 227 | 228 | func TestUpdateActivityByForeignID(t *testing.T) { 229 | ctx := context.Background() 230 | client, requester := newClient(t) 231 | 232 | tt := stream.Time{Time: time.Date(2018, 6, 24, 11, 28, 0, 0, time.UTC)} 233 | 234 | _, err := client.UpdateActivityByForeignID(ctx, "fid:123", tt, map[string]any{"foo.bar": "baz", "popularity": 42, "color": map[string]any{"hex": "FF0000", "rgb": "255,0,0"}}, []string{"a", "b", "c"}) 235 | require.NoError(t, err) 236 | body := `{"foreign_id":"fid:123","time":"2018-06-24T11:28:00","set":{"color":{"hex":"FF0000","rgb":"255,0,0"},"foo.bar":"baz","popularity":42},"unset":["a","b","c"]}` 237 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 238 | 239 | _, err = client.UpdateActivityByForeignID(ctx, "fid:123", tt, map[string]any{"foo.bar": "baz", "popularity": 42, "color": map[string]any{"hex": "FF0000", "rgb": "255,0,0"}}, nil) 240 | require.NoError(t, err) 241 | body = `{"foreign_id":"fid:123","time":"2018-06-24T11:28:00","set":{"color":{"hex":"FF0000","rgb":"255,0,0"},"foo.bar":"baz","popularity":42}}` 242 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 243 | 244 | _, err = client.UpdateActivityByForeignID(ctx, "fid:123", tt, nil, []string{"a", "b", "c"}) 245 | require.NoError(t, err) 246 | body = `{"foreign_id":"fid:123","time":"2018-06-24T11:28:00","unset":["a","b","c"]}` 247 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activity/?api_key=key", body) 248 | } 249 | 250 | func TestUserSessionToken(t *testing.T) { 251 | client, _ := newClient(t) 252 | tokenString, err := client.CreateUserToken("user") 253 | require.NoError(t, err) 254 | assert.Equal(t, tokenString, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidXNlciJ9.0Kiui6HUywyU-C-00E68n1iq_3o7Eh0aE5VGSOc3pU4") 255 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { return []byte("secret"), nil }) 256 | require.NoError(t, err) 257 | assert.Equal(t, true, token.Valid) 258 | assert.Equal(t, token.Claims, jwt.MapClaims{"user_id": "user"}) 259 | } 260 | 261 | func TestUserSessionTokenWithClaims(t *testing.T) { 262 | client, _ := newClient(t) 263 | tokenString, err := client.CreateUserTokenWithClaims("user", map[string]any{"client": "go"}) 264 | require.NoError(t, err) 265 | assert.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQiOiJnbyIsInVzZXJfaWQiOiJ1c2VyIn0.Us6UIuH83dJe1cXQIiudseFz9-1kVMr6-SL6-idzIB0", tokenString) 266 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { return []byte("secret"), nil }) 267 | require.NoError(t, err) 268 | assert.Equal(t, true, token.Valid) 269 | assert.Equal(t, token.Claims, jwt.MapClaims{"user_id": "user", "client": "go"}) 270 | } 271 | -------------------------------------------------------------------------------- /collections.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | ) 10 | 11 | // CollectionsClient is a specialized client used to interact with the Collection endpoints. 12 | type CollectionsClient struct { 13 | client *Client 14 | } 15 | 16 | // Upsert creates new or updates existing objects for the given collection's name. 17 | func (c *CollectionsClient) Upsert(ctx context.Context, collection string, objects ...CollectionObject) (*BaseResponse, error) { 18 | if collection == "" { 19 | return nil, errors.New("collection name required") 20 | } 21 | endpoint := c.client.makeEndpoint("collections/") 22 | data := map[string]any{ 23 | "data": map[string][]CollectionObject{ 24 | collection: objects, 25 | }, 26 | } 27 | return decode(c.client.post(ctx, endpoint, data, c.client.authenticator.collectionsAuth)) 28 | } 29 | 30 | // Select returns a list of CollectionObjects for the given collection name 31 | // having the given IDs. 32 | func (c *CollectionsClient) Select(ctx context.Context, collection string, ids ...string) (*GetCollectionResponse, error) { 33 | if collection == "" { 34 | return nil, errors.New("collection name required") 35 | } 36 | foreignIDs := make([]string, len(ids)) 37 | for i := range ids { 38 | foreignIDs[i] = fmt.Sprintf("%s:%s", collection, ids[i]) 39 | } 40 | endpoint := c.client.makeEndpoint("collections/") 41 | endpoint.addQueryParam(makeRequestOption("foreign_ids", strings.Join(foreignIDs, ","))) 42 | resp, err := c.client.get(ctx, endpoint, nil, c.client.authenticator.collectionsAuth) 43 | if err != nil { 44 | return nil, err 45 | } 46 | var result getCollectionResponseWrap 47 | if err := json.Unmarshal(resp, &result); err != nil { 48 | return nil, err 49 | } 50 | return &GetCollectionResponse{ 51 | response: result.response, 52 | Objects: result.Response.Data, 53 | }, nil 54 | } 55 | 56 | // DeleteMany removes from a collection the objects having the given IDs. 57 | func (c *CollectionsClient) DeleteMany(ctx context.Context, collection string, ids ...string) (*BaseResponse, error) { 58 | if collection == "" { 59 | return nil, errors.New("collection name required") 60 | } 61 | endpoint := c.client.makeEndpoint("collections/") 62 | endpoint.addQueryParam(makeRequestOption("collection_name", collection)) 63 | endpoint.addQueryParam(makeRequestOption("ids", strings.Join(ids, ","))) 64 | return decode(c.client.delete(ctx, endpoint, nil, c.client.authenticator.collectionsAuth)) 65 | } 66 | 67 | func (c *CollectionsClient) decodeObject(resp []byte, err error) (*CollectionObjectResponse, error) { 68 | if err != nil { 69 | return nil, err 70 | } 71 | var result CollectionObjectResponse 72 | if err := json.Unmarshal(resp, &result); err != nil { 73 | return nil, err 74 | } 75 | return &result, nil 76 | } 77 | 78 | // Add adds a single object to a collection. 79 | func (c *CollectionsClient) Add(ctx context.Context, collection string, object CollectionObject, opts ...AddObjectOption) (*CollectionObjectResponse, error) { 80 | if collection == "" { 81 | return nil, errors.New("collection name required") 82 | } 83 | endpoint := c.client.makeEndpoint("collections/%s/", collection) 84 | 85 | req := addCollectionRequest{} 86 | 87 | for _, opt := range opts { 88 | opt(&req) 89 | } 90 | 91 | req.ID = object.ID 92 | req.Data = object.Data 93 | 94 | return c.decodeObject(c.client.post(ctx, endpoint, req, c.client.authenticator.collectionsAuth)) 95 | } 96 | 97 | // Get retrieves a collection object having the given ID. 98 | func (c *CollectionsClient) Get(ctx context.Context, collection, id string) (*CollectionObjectResponse, error) { 99 | if collection == "" { 100 | return nil, errors.New("collection name required") 101 | } 102 | endpoint := c.client.makeEndpoint("collections/%s/%s/", collection, id) 103 | 104 | return c.decodeObject(c.client.get(ctx, endpoint, nil, c.client.authenticator.collectionsAuth)) 105 | } 106 | 107 | // Update updates the given collection object's data. 108 | func (c *CollectionsClient) Update(ctx context.Context, collection, id string, data map[string]any) (*CollectionObjectResponse, error) { 109 | if collection == "" { 110 | return nil, errors.New("collection name required") 111 | } 112 | endpoint := c.client.makeEndpoint("collections/%s/%s/", collection, id) 113 | reqData := map[string]any{ 114 | "data": data, 115 | } 116 | 117 | return c.decodeObject(c.client.put(ctx, endpoint, reqData, c.client.authenticator.collectionsAuth)) 118 | } 119 | 120 | // Delete removes from a collection the object having the given ID. 121 | func (c *CollectionsClient) Delete(ctx context.Context, collection, id string) (*BaseResponse, error) { 122 | if collection == "" { 123 | return nil, errors.New("collection name required") 124 | } 125 | endpoint := c.client.makeEndpoint("collections/%s/%s/", collection, id) 126 | 127 | return decode(c.client.delete(ctx, endpoint, nil, c.client.authenticator.collectionsAuth)) 128 | } 129 | 130 | // CreateReference returns a new reference string in the form SO::. 131 | func (c *CollectionsClient) CreateReference(collection, id string) string { 132 | return fmt.Sprintf("SO:%s:%s", collection, id) 133 | } 134 | 135 | // CreateCollectionReference is a convenience helper not to require a client. 136 | var CreateCollectionReference = (&CollectionsClient{}).CreateReference 137 | -------------------------------------------------------------------------------- /collections_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | stream "github.com/GetStream/stream-go2/v8" 13 | ) 14 | 15 | func TestCollectionRefHelpers(t *testing.T) { 16 | client, _ := newClient(t) 17 | ref := client.Collections().CreateReference("foo", "bar") 18 | assert.Equal(t, "SO:foo:bar", ref) 19 | } 20 | 21 | func TestUpsertCollectionObjects(t *testing.T) { 22 | ctx := context.Background() 23 | client, requester := newClient(t) 24 | testCases := []struct { 25 | objects []stream.CollectionObject 26 | collection string 27 | expectedURL string 28 | expectedBody string 29 | }{ 30 | { 31 | collection: "test-single", 32 | objects: []stream.CollectionObject{ 33 | { 34 | ID: "1", 35 | Data: map[string]any{ 36 | "name": "Juniper", 37 | "hobbies": []string{"playing", "sleeping", "eating"}, 38 | }, 39 | }, 40 | }, 41 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/?api_key=key", 42 | expectedBody: `{"data":{"test-single":[{"hobbies":["playing","sleeping","eating"],"id":"1","name":"Juniper"}]}}`, 43 | }, 44 | { 45 | collection: "test-many", 46 | objects: []stream.CollectionObject{ 47 | { 48 | ID: "1", 49 | Data: map[string]any{ 50 | "name": "Juniper", 51 | "hobbies": []string{"playing", "sleeping", "eating"}, 52 | }, 53 | }, 54 | { 55 | ID: "2", 56 | Data: map[string]any{ 57 | "name": "Ruby", 58 | "interests": []string{"sunbeams", "surprise attacks"}, 59 | }, 60 | }, 61 | }, 62 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/?api_key=key", 63 | expectedBody: `{"data":{"test-many":[{"hobbies":["playing","sleeping","eating"],"id":"1","name":"Juniper"},{"id":"2","interests":["sunbeams","surprise attacks"],"name":"Ruby"}]}}`, 64 | }, 65 | } 66 | for _, tc := range testCases { 67 | _, err := client.Collections().Upsert(ctx, tc.collection, tc.objects...) 68 | require.NoError(t, err) 69 | testRequest(t, requester.req, http.MethodPost, tc.expectedURL, tc.expectedBody) 70 | } 71 | } 72 | 73 | func TestSelectCollectionObjects(t *testing.T) { 74 | ctx := context.Background() 75 | client, requester := newClient(t) 76 | testCases := []struct { 77 | ids []string 78 | collection string 79 | expectedURL string 80 | expectedBody string 81 | }{ 82 | { 83 | collection: "test-single", 84 | ids: []string{"one"}, 85 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/?api_key=key&foreign_ids=" + url.QueryEscape("test-single:one"), 86 | }, 87 | { 88 | collection: "test-multiple", 89 | ids: []string{"one", "two", "three"}, 90 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/?api_key=key&foreign_ids=" + url.QueryEscape("test-multiple:one,test-multiple:two,test-multiple:three"), 91 | }, 92 | } 93 | for _, tc := range testCases { 94 | _, err := client.Collections().Select(ctx, tc.collection, tc.ids...) 95 | require.NoError(t, err) 96 | testRequest(t, requester.req, http.MethodGet, tc.expectedURL, tc.expectedBody) 97 | } 98 | } 99 | 100 | func TestDeleteManyCollectionObjects(t *testing.T) { 101 | ctx := context.Background() 102 | client, requester := newClient(t) 103 | testCases := []struct { 104 | ids []string 105 | collection string 106 | expectedURL string 107 | }{ 108 | { 109 | collection: "test-single", 110 | ids: []string{"one"}, 111 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/?api_key=key&collection_name=test-single&ids=one", 112 | }, 113 | { 114 | collection: "test-many", 115 | ids: []string{"one", "two", "three"}, 116 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/?api_key=key&collection_name=test-many&ids=one%2Ctwo%2Cthree", 117 | }, 118 | } 119 | for _, tc := range testCases { 120 | _, err := client.Collections().DeleteMany(ctx, tc.collection, tc.ids...) 121 | require.NoError(t, err) 122 | testRequest(t, requester.req, http.MethodDelete, tc.expectedURL, "") 123 | } 124 | } 125 | 126 | func TestGetCollectionObject(t *testing.T) { 127 | ctx := context.Background() 128 | client, requester := newClient(t) 129 | 130 | _, err := client.Collections().Get(ctx, "test-get-one", "id1") 131 | require.NoError(t, err) 132 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/collections/test-get-one/id1/?api_key=key", "") 133 | } 134 | 135 | func TestDeleteCollectionObject(t *testing.T) { 136 | ctx := context.Background() 137 | client, requester := newClient(t) 138 | 139 | _, err := client.Collections().Delete(ctx, "test-get-one", "id1") 140 | require.NoError(t, err) 141 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/collections/test-get-one/id1/?api_key=key", "") 142 | } 143 | 144 | func TestAddCollectionObject(t *testing.T) { 145 | ctx := context.Background() 146 | client, requester := newClient(t) 147 | testCases := []struct { 148 | object stream.CollectionObject 149 | collection string 150 | opts []stream.AddObjectOption 151 | expectedURL string 152 | expectedBody string 153 | }{ 154 | { 155 | collection: "no_user_id", 156 | object: stream.CollectionObject{ 157 | ID: "1", 158 | Data: map[string]any{ 159 | "name": "Juniper", 160 | "hobbies": []string{"playing", "sleeping", "eating"}, 161 | }, 162 | }, 163 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/no_user_id/?api_key=key", 164 | expectedBody: `{"data":{"hobbies":["playing","sleeping","eating"],"name":"Juniper"},"id":"1"}`, 165 | }, 166 | { 167 | collection: "with_user_id", 168 | object: stream.CollectionObject{ 169 | ID: "1", 170 | Data: map[string]any{ 171 | "name": "Juniper", 172 | "hobbies": []string{"playing", "sleeping", "eating"}, 173 | }, 174 | }, 175 | opts: []stream.AddObjectOption{stream.WithUserID("user1")}, 176 | expectedURL: "https://api.stream-io-api.com/api/v1.0/collections/with_user_id/?api_key=key", 177 | expectedBody: `{"data":{"hobbies":["playing","sleeping","eating"],"name":"Juniper"},"id":"1","user_id":"user1"}`, 178 | }, 179 | } 180 | for _, tc := range testCases { 181 | _, err := client.Collections().Add(ctx, tc.collection, tc.object, tc.opts...) 182 | require.NoError(t, err) 183 | testRequest(t, requester.req, http.MethodPost, tc.expectedURL, tc.expectedBody) 184 | } 185 | } 186 | 187 | func TestUpdateCollectionObject(t *testing.T) { 188 | ctx := context.Background() 189 | client, requester := newClient(t) 190 | 191 | data := map[string]any{ 192 | "name": "Jane", 193 | } 194 | _, err := client.Collections().Update(ctx, "test-collection", "123", data) 195 | require.NoError(t, err) 196 | expectedBody := `{"data":{"name":"Jane"}}` 197 | testRequest(t, requester.req, http.MethodPut, "https://api.stream-io-api.com/api/v1.0/collections/test-collection/123/?api_key=key", expectedBody) 198 | } 199 | -------------------------------------------------------------------------------- /enriched_activities.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/fatih/structs" 11 | ) 12 | 13 | // EnrichedActivity is an enriched Stream activity entity. 14 | type EnrichedActivity struct { 15 | ID string `json:"id,omitempty"` 16 | Actor Data `json:"actor,omitempty"` 17 | Verb string `json:"verb,omitempty"` 18 | Object Data `json:"object,omitempty"` 19 | ForeignID string `json:"foreign_id,omitempty"` 20 | Target Data `json:"target,omitempty"` 21 | Time Time `json:"time,omitempty"` 22 | Origin Data `json:"origin,omitempty"` 23 | To []string `json:"to,omitempty"` 24 | Score float64 `json:"score,omitempty"` 25 | ReactionCounts map[string]int `json:"reaction_counts,omitempty"` 26 | OwnReactions map[string][]*EnrichedReaction `json:"own_reactions,omitempty"` 27 | LatestReactions map[string][]*EnrichedReaction `json:"latest_reactions,omitempty"` 28 | Extra map[string]any `json:"-"` 29 | } 30 | 31 | // EnrichedReaction is an enriched Stream reaction entity. 32 | type EnrichedReaction struct { 33 | ID string `json:"id,omitempty"` 34 | Kind string `json:"kind"` 35 | ActivityID string `json:"activity_id"` 36 | UserID string `json:"user_id"` 37 | Data map[string]any `json:"data,omitempty"` 38 | TargetFeeds []string `json:"target_feeds,omitempty"` 39 | ParentID string `json:"parent,omitempty"` 40 | ChildrenReactions map[string][]*EnrichedReaction `json:"latest_children,omitempty"` 41 | OwnChildren map[string][]*EnrichedReaction `json:"own_children,omitempty"` 42 | ChildrenCounters map[string]int `json:"children_counts,omitempty"` 43 | User Data `json:"user,omitempty"` 44 | CreatedAt Time `json:"created_at,omitempty"` 45 | UpdatedAt Time `json:"updated_at,omitempty"` 46 | } 47 | 48 | // UnmarshalJSON decodes the provided JSON payload into the EnrichedActivity. It's required 49 | // because of the custom JSON fields and time formats. 50 | func (a *EnrichedActivity) UnmarshalJSON(b []byte) error { 51 | var data map[string]any 52 | if err := json.Unmarshal(b, &data); err != nil { 53 | return err 54 | } 55 | 56 | if _, ok := data["to"]; ok { 57 | tos := data["to"].([]any) 58 | simpleTos := make([]string, len(tos)) 59 | for i := range tos { 60 | switch to := tos[i].(type) { 61 | case string: 62 | simpleTos[i] = to 63 | case []any: 64 | tos, ok := to[0].(string) 65 | if !ok { 66 | return errors.New("invalid format for to targets") 67 | } 68 | simpleTos[i] = tos 69 | } 70 | } 71 | data["to"] = simpleTos 72 | } 73 | 74 | if v, ok := data["foreign_id"]; ok { // handle activity reference in foreign id 75 | if val, ok := v.(map[string]any); ok { 76 | id, ok := val["id"].(string) 77 | if !ok { 78 | return fmt.Errorf("invalid format for enriched referenced activity id: %v", val["id"]) 79 | } 80 | data["foreign_id_ref"] = data["foreign_id"] 81 | data["foreign_id"] = "SA:" + id 82 | } 83 | } 84 | 85 | return a.decode(data) 86 | } 87 | 88 | // MarshalJSON encodes the EnrichedActivity to a valid JSON bytes slice. It's required because of 89 | // the custom JSON fields and time formats. 90 | func (a EnrichedActivity) MarshalJSON() ([]byte, error) { 91 | s := structs.New(a) 92 | fields := s.Fields() 93 | data := s.Map() 94 | for _, f := range fields { 95 | tag := f.Tag("json") 96 | root := strings.TrimSuffix(tag, ",omitempty") 97 | 98 | if f.Kind() != reflect.Struct || 99 | (strings.HasSuffix(tag, ",omitempty") && 100 | structs.IsZero(f.Value())) { 101 | continue 102 | } 103 | 104 | data[root] = f.Value() 105 | } 106 | for k, v := range a.Extra { 107 | data[k] = v 108 | } 109 | 110 | if _, ok := data["time"]; ok { 111 | data["time"] = a.Time.Format(TimeLayout) 112 | } 113 | return json.Marshal(data) 114 | } 115 | 116 | func (a *EnrichedActivity) decode(data map[string]any) error { 117 | meta, err := decodeData(data, a) 118 | if err != nil { 119 | return err 120 | } 121 | if len(meta.Unused) > 0 { 122 | a.Extra = make(map[string]any) 123 | for _, k := range meta.Unused { 124 | a.Extra[k] = data[k] 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | // EnrichedActivityGroup is a group of enriched Activities obtained from aggregated feeds. 131 | type EnrichedActivityGroup struct { 132 | baseActivityGroup 133 | Activities []EnrichedActivity `json:"activities,omitempty"` 134 | Score float64 `json:"score,omitempty"` 135 | } 136 | 137 | // EnrichedNotificationFeedResult is a notification-feed specific response, containing 138 | // the list of enriched activities in the group, plus the extra fields about the group read+seen status. 139 | type EnrichedNotificationFeedResult struct { 140 | baseNotificationFeedResult 141 | Activities []EnrichedActivity `json:"activities"` 142 | } 143 | -------------------------------------------------------------------------------- /enriched_activities_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | stream "github.com/GetStream/stream-go2/v8" 12 | ) 13 | 14 | func TestEnrichedActivityUnmarshalJSON(t *testing.T) { 15 | now := getTime(time.Now()) 16 | testCases := []struct { 17 | activity stream.EnrichedActivity 18 | data []byte 19 | }{ 20 | { 21 | activity: stream.EnrichedActivity{Actor: stream.Data{ID: "actor"}, Verb: "verb", Object: stream.Data{ID: "object"}}, 22 | data: []byte(`{"actor":"actor","object":"object","verb":"verb"}`), 23 | }, 24 | { 25 | activity: stream.EnrichedActivity{Actor: stream.Data{ID: "actor"}, Verb: "verb", Object: stream.Data{ID: "object"}, Time: now}, 26 | data: []byte(`{"actor":"actor","object":"object","time":"` + now.Format(stream.TimeLayout) + `","verb":"verb"}`), 27 | }, 28 | { 29 | activity: stream.EnrichedActivity{Actor: stream.Data{ID: "actor"}, Verb: "verb", Object: stream.Data{ID: "object"}, Time: now, Extra: map[string]any{"popularity": 42.0, "size": map[string]any{"width": 800.0, "height": 600.0}}}, 30 | data: []byte(`{"actor":"actor","object":"object","popularity":42,"size":{"height":600,"width":800},"time":"` + now.Format(stream.TimeLayout) + `","verb":"verb"}`), 31 | }, 32 | { 33 | activity: stream.EnrichedActivity{Actor: stream.Data{ID: "actor"}, Verb: "verb", Object: stream.Data{ID: "object"}, Time: now, Extra: map[string]any{"popularity": 42.0, "size": map[string]any{"width": 800.0, "height": 600.0}}}, 34 | data: []byte(`{"actor":"actor","object":"object","popularity":42,"size":{"height":600,"width":800},"time":"` + now.Format(stream.TimeLayout) + `","verb":"verb"}`), 35 | }, 36 | { 37 | activity: stream.EnrichedActivity{To: []string{"abcd", "efgh"}}, 38 | data: []byte(`{"to":["abcd","efgh"]}`), 39 | }, 40 | { 41 | activity: stream.EnrichedActivity{ 42 | ForeignID: "SA:123", 43 | Extra: map[string]any{ 44 | "foreign_id_ref": map[string]any{ 45 | "id": "123", 46 | "extra_prop": true, 47 | }, 48 | }, 49 | }, 50 | data: []byte(`{"foreign_id":{"id":"123","extra_prop":true}}`), 51 | }, 52 | } 53 | for _, tc := range testCases { 54 | var out stream.EnrichedActivity 55 | err := json.Unmarshal(tc.data, &out) 56 | require.NoError(t, err) 57 | assert.Equal(t, tc.activity, out) 58 | } 59 | } 60 | 61 | func TestEnrichedActivityUnmarshalJSON_toTargets(t *testing.T) { 62 | testCases := []struct { 63 | activity stream.EnrichedActivity 64 | data []byte 65 | shouldError bool 66 | }{ 67 | { 68 | activity: stream.EnrichedActivity{To: []string{"abcd", "efgh"}}, 69 | data: []byte(`{"to":["abcd","efgh"]}`), 70 | }, 71 | { 72 | activity: stream.EnrichedActivity{To: []string{"abcd", "efgh"}}, 73 | data: []byte(`{"to":[["abcd", "foo"], ["efgh", "bar"]]}`), 74 | }, 75 | { 76 | activity: stream.EnrichedActivity{To: []string{"abcd", "efgh"}}, 77 | data: []byte(`{"to":[[123]]}`), 78 | shouldError: true, 79 | }, 80 | } 81 | for _, tc := range testCases { 82 | var out stream.EnrichedActivity 83 | err := json.Unmarshal(tc.data, &out) 84 | if tc.shouldError { 85 | require.Error(t, err) 86 | } else { 87 | require.NoError(t, err) 88 | assert.Equal(t, tc.activity, out) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | errMissingCredentials = errors.New("missing API key or secret") 9 | errInvalidUserID = errors.New("invalid userID provided") 10 | errToTargetsNoChanges = errors.New("no changes specified, please supply new targets or added/removed targets") 11 | ) 12 | 13 | // Rate limit headers 14 | const ( 15 | HeaderRateLimit = "X-Ratelimit-Limit" 16 | HeaderRateRemaining = "X-Ratelimit-Remaining" 17 | HeaderRateReset = "X-Ratelimit-Reset" 18 | ) 19 | 20 | // APIError is an error returned by Stream API when the request cannot be 21 | // performed or errored server side. 22 | type APIError struct { 23 | Code int `json:"code,omitempty"` 24 | Detail string `json:"detail,omitempty"` 25 | Duration Duration `json:"duration,omitempty"` 26 | Exception string `json:"exception,omitempty"` 27 | ExceptionFields map[string][]any `json:"exception_fields,omitempty"` 28 | StatusCode int `json:"status_code,omitempty"` 29 | Rate *Rate `json:"-"` 30 | } 31 | 32 | func (e APIError) Error() string { 33 | return e.Detail 34 | } 35 | 36 | // ToAPIError tries to cast the provided error to APIError type, returning the 37 | // obtained APIError and whether the operation was successful. 38 | func ToAPIError(err error) (APIError, bool) { 39 | se, ok := err.(APIError) 40 | return se, ok 41 | } 42 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | stream "github.com/GetStream/stream-go2/v8" 12 | ) 13 | 14 | func TestErrorUnmarshal(t *testing.T) { 15 | data := []byte(`{"code":42,"detail":"the details","duration":"10ms","exception":"boom","status_code":123}`) 16 | var apiErr stream.APIError 17 | err := json.Unmarshal(data, &apiErr) 18 | assert.NoError(t, err) 19 | expected := stream.APIError{ 20 | Code: 42, 21 | Detail: "the details", 22 | Duration: stream.Duration{Duration: 10 * time.Millisecond}, 23 | Exception: "boom", 24 | StatusCode: 123, 25 | } 26 | assert.Equal(t, expected, apiErr) 27 | } 28 | 29 | func TestErrorString(t *testing.T) { 30 | apiErr := stream.APIError{Detail: "boom"} 31 | assert.Equal(t, "boom", apiErr.Error()) 32 | } 33 | 34 | func TestToAPIError(t *testing.T) { 35 | testCases := []struct { 36 | err error 37 | match bool 38 | }{ 39 | { 40 | err: fmt.Errorf("this is an error"), 41 | match: false, 42 | }, 43 | { 44 | err: stream.APIError{}, 45 | match: true, 46 | }, 47 | } 48 | 49 | for _, tc := range testCases { 50 | err, ok := stream.ToAPIError(tc.err) 51 | assert.Equal(t, tc.match, ok) 52 | if ok { 53 | assert.IsType(t, stream.APIError{}, err) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /feed.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | var ( 10 | _ Feed = (*FlatFeed)(nil) 11 | _ Feed = (*AggregatedFeed)(nil) 12 | _ Feed = (*NotificationFeed)(nil) 13 | ) 14 | 15 | // Feed is a generic Stream feed, exporting the generic functions common to any 16 | // Stream feed. 17 | type Feed interface { 18 | ID() string 19 | Slug() string 20 | UserID() string 21 | AddActivity(context.Context, Activity) (*AddActivityResponse, error) 22 | AddActivities(context.Context, ...Activity) (*AddActivitiesResponse, error) 23 | RemoveActivityByID(context.Context, string, ...RemoveActivityOption) (*RemoveActivityResponse, error) 24 | RemoveActivityByForeignID(context.Context, string) (*RemoveActivityResponse, error) 25 | Follow(context.Context, *FlatFeed, ...FollowFeedOption) (*BaseResponse, error) 26 | GetFollowing(context.Context, ...FollowingOption) (*FollowingResponse, error) 27 | Unfollow(context.Context, Feed, ...UnfollowOption) (*BaseResponse, error) 28 | UpdateToTargets(context.Context, Activity, ...UpdateToTargetsOption) (*UpdateToTargetsResponse, error) 29 | BatchUpdateToTargets(context.Context, []UpdateToTargetsRequest) (*UpdateToTargetsResponse, error) 30 | RealtimeToken(bool) string 31 | } 32 | 33 | const feedSlugIDSeparator = ":" 34 | 35 | var userIDRegex *regexp.Regexp 36 | 37 | func init() { 38 | userIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) 39 | } 40 | 41 | type feed struct { 42 | client *Client 43 | slug string 44 | userID string 45 | } 46 | 47 | // ID returns the feed ID, as slug:user_id. 48 | func (f *feed) ID() string { 49 | return fmt.Sprintf("%s%s%s", f.slug, feedSlugIDSeparator, f.userID) 50 | } 51 | 52 | // Slug returns the feed's slug. 53 | func (f *feed) Slug() string { 54 | return f.slug 55 | } 56 | 57 | // UserID returns the feed's user_id. 58 | func (f *feed) UserID() string { 59 | return f.userID 60 | } 61 | 62 | func newFeed(slug, userID string, client *Client) (*feed, error) { 63 | ok := userIDRegex.MatchString(userID) 64 | if !ok { 65 | return nil, errInvalidUserID 66 | } 67 | return &feed{userID: userID, slug: slug, client: client}, nil 68 | } 69 | 70 | // AddActivity adds a new Activity to the feed. 71 | func (f *feed) AddActivity(ctx context.Context, activity Activity) (*AddActivityResponse, error) { 72 | return f.client.addActivity(ctx, f, activity) 73 | } 74 | 75 | // AddActivities adds multiple activities to the feed. 76 | func (f *feed) AddActivities(ctx context.Context, activities ...Activity) (*AddActivitiesResponse, error) { 77 | return f.client.addActivities(ctx, f, activities...) 78 | } 79 | 80 | // RemoveActivityByID removes an activity from the feed (if present), using the provided 81 | // id string argument as the ID field of the activity. 82 | // Optional RemoveActivityOption parameters can be provided, such as WithRemoveByUserID 83 | // to specify a user ID in the query string. 84 | func (f *feed) RemoveActivityByID(ctx context.Context, id string, opts ...RemoveActivityOption) (*RemoveActivityResponse, error) { 85 | return f.client.removeActivityByID(ctx, f, id, opts...) 86 | } 87 | 88 | // RemoveActivityByForeignID removes an activity from the feed (if present), using the provided 89 | // foreignID string argument as the foreign_id field of the activity. 90 | func (f *feed) RemoveActivityByForeignID(ctx context.Context, foreignID string) (*RemoveActivityResponse, error) { 91 | return f.client.removeActivityByForeignID(ctx, f, foreignID) 92 | } 93 | 94 | // Follow follows the provided feed (which must be a FlatFeed), applying the provided FollowFeedOptions, 95 | // if any. 96 | func (f *feed) Follow(ctx context.Context, feed *FlatFeed, opts ...FollowFeedOption) (*BaseResponse, error) { 97 | followOptions := &followFeedOptions{ 98 | Target: fmt.Sprintf("%s:%s", feed.Slug(), feed.UserID()), 99 | ActivityCopyLimit: defaultActivityCopyLimit, 100 | } 101 | for _, opt := range opts { 102 | opt(followOptions) 103 | } 104 | return f.client.follow(ctx, f, followOptions) 105 | } 106 | 107 | // GetFollowing returns the list of the feeds following the feed, applying the provided FollowingOptions, 108 | // if any. 109 | func (f *feed) GetFollowing(ctx context.Context, opts ...FollowingOption) (*FollowingResponse, error) { 110 | return f.client.getFollowing(ctx, f, opts...) 111 | } 112 | 113 | // Unfollow unfollows the provided feed, applying the provided UnfollowOptions, if any. 114 | func (f *feed) Unfollow(ctx context.Context, target Feed, opts ...UnfollowOption) (*BaseResponse, error) { 115 | return f.client.unfollow(ctx, f, target.ID(), opts...) 116 | } 117 | 118 | // UpdateToTargets updates the "to" targets for the provided activity, with the options passed 119 | // as argument for replacing, adding, or removing to targets. 120 | func (f *feed) UpdateToTargets(ctx context.Context, activity Activity, opts ...UpdateToTargetsOption) (*UpdateToTargetsResponse, error) { 121 | return f.client.updateToTargets(ctx, f, activity, opts...) 122 | } 123 | 124 | // BatchUpdateToTargets updates the "to" targets for up to 100 activities, with the options passed 125 | // as argument for replacing, adding, or removing to targets. 126 | // NOTE: Only the first update is executed synchronously (same response as UpdateToTargets()), the remaining N-1 updates will be put in a worker queue and executed asynchronously. 127 | func (f *feed) BatchUpdateToTargets(ctx context.Context, reqs []UpdateToTargetsRequest) (*UpdateToTargetsResponse, error) { 128 | return f.client.batchUpdateToTargets(ctx, f, reqs) 129 | } 130 | 131 | // RealtimeToken returns a token that can be used client-side to listen in real-time to feed changes. 132 | func (f *feed) RealtimeToken(readonly bool) string { 133 | var action action 134 | if readonly { 135 | action = actionRead 136 | } else { 137 | action = actionWrite 138 | } 139 | id := f.client.authenticator.feedID(f) 140 | claims := f.client.authenticator.jwtFeedClaims(resFeed, action, id) 141 | token, err := f.client.authenticator.jwtSignatureFromClaims(claims) 142 | if err != nil { 143 | return "" 144 | } 145 | return token 146 | } 147 | -------------------------------------------------------------------------------- /feed_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | stream "github.com/GetStream/stream-go2/v8" 14 | ) 15 | 16 | func TestFeedID(t *testing.T) { 17 | client, _ := newClient(t) 18 | flat, _ := client.FlatFeed("flat", "123") 19 | assert.Equal(t, "flat:123", flat.ID()) 20 | aggregated, _ := client.AggregatedFeed("aggregated", "456") 21 | assert.Equal(t, "aggregated:456", aggregated.ID()) 22 | } 23 | 24 | func TestInvalidFeedUserID(t *testing.T) { 25 | client, _ := newClient(t) 26 | 27 | _, err := client.FlatFeed("flat", "jones:134") 28 | assert.NotNil(t, err) 29 | assert.Equal(t, "invalid userID provided", err.Error()) 30 | 31 | _, err = client.AggregatedFeed("aggregated", "jones,kim") 32 | assert.NotNil(t, err) 33 | assert.Equal(t, "invalid userID provided", err.Error()) 34 | } 35 | 36 | func TestAddActivity(t *testing.T) { 37 | var ( 38 | client, requester = newClient(t) 39 | ctx = context.Background() 40 | flat, _ = newFlatFeedWithUserID(client, "123") 41 | bobActivity = stream.Activity{Actor: "bob", Verb: "like", Object: "ice-cream", To: []string{"flat:456"}} 42 | ) 43 | _, err := flat.AddActivity(ctx, bobActivity) 44 | require.NoError(t, err) 45 | body := `{"actor":"bob","object":"ice-cream","to":["flat:456"],"verb":"like"}` 46 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key", body) 47 | 48 | requester.resp = `{"duration": "1ms"}` 49 | _, err = flat.AddActivity(ctx, bobActivity) 50 | require.NoError(t, err) 51 | } 52 | 53 | func TestAddActivities(t *testing.T) { 54 | var ( 55 | client, requester = newClient(t) 56 | ctx = context.Background() 57 | flat, _ = newFlatFeedWithUserID(client, "123") 58 | bobActivity = stream.Activity{Actor: "bob", Verb: "like", Object: "ice-cream"} 59 | aliceActivity = stream.Activity{Actor: "alice", Verb: "dislike", Object: "ice-cream", To: []string{"flat:456"}} 60 | ) 61 | _, err := flat.AddActivities(ctx, bobActivity, aliceActivity) 62 | require.NoError(t, err) 63 | body := `{"activities":[{"actor":"bob","object":"ice-cream","verb":"like"},{"actor":"alice","object":"ice-cream","to":["flat:456"],"verb":"dislike"}]}` 64 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key", body) 65 | } 66 | 67 | func TestUpdateActivities(t *testing.T) { 68 | var ( 69 | client, requester = newClient(t) 70 | ctx = context.Background() 71 | now = getTime(time.Now()) 72 | bobActivity = stream.Activity{ 73 | Actor: "bob", 74 | Verb: "like", 75 | Object: "ice-cream", 76 | ForeignID: "bob:123", 77 | Time: now, 78 | Extra: map[string]any{"influence": 42}, 79 | } 80 | ) 81 | _, err := client.UpdateActivities(ctx, bobActivity) 82 | require.NoError(t, err) 83 | 84 | body := fmt.Sprintf(`{"activities":[{"actor":"bob","foreign_id":"bob:123","influence":42,"object":"ice-cream","time":%q,"verb":"like"}]}`, now.Format(stream.TimeLayout)) 85 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/activities/?api_key=key", body) 86 | } 87 | 88 | func TestFollow(t *testing.T) { 89 | var ( 90 | client, requester = newClient(t) 91 | ctx = context.Background() 92 | f1, _ = newFlatFeedWithUserID(client, "f1") 93 | f2, _ = newFlatFeedWithUserID(client, "f2") 94 | ) 95 | testCases := []struct { 96 | opts []stream.FollowFeedOption 97 | expectedURL string 98 | expectedBody string 99 | }{ 100 | { 101 | expectedURL: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/?api_key=key", 102 | expectedBody: `{"target":"flat:f2","activity_copy_limit":300}`, 103 | }, 104 | { 105 | opts: []stream.FollowFeedOption{stream.WithFollowFeedActivityCopyLimit(123)}, 106 | expectedURL: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/?api_key=key", 107 | expectedBody: `{"target":"flat:f2","activity_copy_limit":123}`, 108 | }, 109 | } 110 | for _, tc := range testCases { 111 | _, err := f1.Follow(ctx, f2, tc.opts...) 112 | require.NoError(t, err) 113 | testRequest(t, requester.req, http.MethodPost, tc.expectedURL, tc.expectedBody) 114 | } 115 | } 116 | 117 | func TestGetFollowing(t *testing.T) { 118 | var ( 119 | client, requester = newClient(t) 120 | ctx = context.Background() 121 | f1, _ = newFlatFeedWithUserID(client, "f1") 122 | ) 123 | testCases := []struct { 124 | opts []stream.FollowingOption 125 | expected string 126 | }{ 127 | { 128 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/?api_key=key", 129 | }, 130 | { 131 | opts: []stream.FollowingOption{stream.WithFollowingFilter("filter"), stream.WithFollowingLimit(42), stream.WithFollowingOffset(84)}, 132 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/?api_key=key&filter=filter&limit=42&offset=84", 133 | }, 134 | } 135 | for _, tc := range testCases { 136 | _, err := f1.GetFollowing(ctx, tc.opts...) 137 | require.NoError(t, err) 138 | testRequest(t, requester.req, http.MethodGet, tc.expected, "") 139 | } 140 | } 141 | 142 | func TestGetFollowers(t *testing.T) { 143 | var ( 144 | client, requester = newClient(t) 145 | ctx = context.Background() 146 | f1, _ = newFlatFeedWithUserID(client, "f1") 147 | ) 148 | testCases := []struct { 149 | opts []stream.FollowersOption 150 | expected string 151 | }{ 152 | { 153 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/followers/?api_key=key", 154 | }, 155 | { 156 | opts: []stream.FollowersOption{stream.WithFollowersLimit(42), stream.WithFollowersOffset(84)}, 157 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/followers/?api_key=key&limit=42&offset=84", 158 | }, 159 | } 160 | for _, tc := range testCases { 161 | _, err := f1.GetFollowers(ctx, tc.opts...) 162 | require.NoError(t, err) 163 | testRequest(t, requester.req, http.MethodGet, tc.expected, "") 164 | } 165 | } 166 | 167 | func TestUnfollow(t *testing.T) { 168 | var ( 169 | client, requester = newClient(t) 170 | ctx = context.Background() 171 | f1, _ = newFlatFeedWithUserID(client, "f1") 172 | f2, _ = newFlatFeedWithUserID(client, "f2") 173 | ) 174 | testCases := []struct { 175 | opts []stream.UnfollowOption 176 | expected string 177 | }{ 178 | { 179 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/flat:f2/?api_key=key", 180 | }, 181 | { 182 | opts: []stream.UnfollowOption{stream.WithUnfollowKeepHistory(false)}, 183 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/flat:f2/?api_key=key", 184 | }, 185 | { 186 | opts: []stream.UnfollowOption{stream.WithUnfollowKeepHistory(true)}, 187 | expected: "https://api.stream-io-api.com/api/v1.0/feed/flat/f1/follows/flat:f2/?api_key=key&keep_history=1", 188 | }, 189 | } 190 | 191 | for _, tc := range testCases { 192 | _, err := f1.Unfollow(ctx, f2, tc.opts...) 193 | require.NoError(t, err) 194 | testRequest(t, requester.req, http.MethodDelete, tc.expected, "") 195 | } 196 | } 197 | 198 | func TestRemoveActivities(t *testing.T) { 199 | ctx := context.Background() 200 | client, requester := newClient(t) 201 | flat, _ := newFlatFeedWithUserID(client, "123") 202 | _, err := flat.RemoveActivityByID(ctx, "id-to-remove") 203 | require.NoError(t, err) 204 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/id-to-remove/?api_key=key", "") 205 | _, err = flat.RemoveActivityByForeignID(ctx, "bob:123") 206 | require.NoError(t, err) 207 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/bob:123/?api_key=key&foreign_id=1", "") 208 | } 209 | 210 | func TestUpdateToTargets(t *testing.T) { 211 | var ( 212 | client, requester = newClient(t) 213 | ctx = context.Background() 214 | flat, _ = newFlatFeedWithUserID(client, "123") 215 | f1, _ = newFlatFeedWithUserID(client, "f1") 216 | f2, _ = newFlatFeedWithUserID(client, "f2") 217 | f3, _ = newFlatFeedWithUserID(client, "f3") 218 | now = getTime(time.Now()) 219 | activity = stream.Activity{Time: now, ForeignID: "bob:123", Actor: "bob", Verb: "like", Object: "ice-cream", To: []string{f1.ID()}, Extra: map[string]any{"popularity": 9000}} 220 | ) 221 | _, err := flat.UpdateToTargets(ctx, activity, stream.WithToTargetsAdd(f2.ID()), stream.WithToTargetsRemove(f1.ID())) 222 | require.NoError(t, err) 223 | body := fmt.Sprintf(`{"foreign_id":"bob:123","time":%q,"added_targets":["flat:f2"],"removed_targets":["flat:f1"]}`, now.Format(stream.TimeLayout)) 224 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed_targets/flat/123/activity_to_targets/?api_key=key", body) 225 | _, err = flat.UpdateToTargets(ctx, activity, stream.WithToTargetsNew(f3.ID())) 226 | require.NoError(t, err) 227 | body = fmt.Sprintf(`{"foreign_id":"bob:123","time":%q,"new_targets":["flat:f3"]}`, now.Format(stream.TimeLayout)) 228 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed_targets/flat/123/activity_to_targets/?api_key=key", body) 229 | 230 | // should error since we have not specified any changes 231 | _, err = flat.UpdateToTargets(ctx, activity) 232 | require.Error(t, err) 233 | } 234 | 235 | func TestBatchUpdateToTargets(t *testing.T) { 236 | var ( 237 | client, requester = newClient(t) 238 | ctx = context.Background() 239 | flat, _ = newFlatFeedWithUserID(client, "123") 240 | f1, _ = newFlatFeedWithUserID(client, "f1") 241 | f2, _ = newFlatFeedWithUserID(client, "f2") 242 | now = getTime(time.Now()) 243 | ) 244 | 245 | reqs := []stream.UpdateToTargetsRequest{ 246 | { 247 | ForeignID: "bob:123", 248 | Time: now, 249 | Opts: []stream.UpdateToTargetsOption{ 250 | stream.WithToTargetsAdd(f2.ID()), 251 | stream.WithToTargetsRemove(f1.ID()), 252 | }, 253 | }, 254 | } 255 | 256 | _, err := flat.BatchUpdateToTargets(ctx, reqs) 257 | require.NoError(t, err) 258 | body := fmt.Sprintf(`[{"foreign_id":"bob:123","time":%q,"added_targets":["flat:f2"],"removed_targets":["flat:f1"]}]`, now.Format(stream.TimeLayout)) 259 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed_targets/flat/123/activity_to_targets/?api_key=key", body) 260 | 261 | reqs = append(reqs, stream.UpdateToTargetsRequest{ 262 | ForeignID: "bob:234", 263 | Time: now, 264 | Opts: []stream.UpdateToTargetsOption{ 265 | stream.WithToTargetsNew(f1.ID(), f2.ID()), 266 | }, 267 | }) 268 | 269 | _, err = flat.BatchUpdateToTargets(ctx, reqs) 270 | require.NoError(t, err) 271 | body = fmt.Sprintf(`[{"foreign_id":"bob:123","time":%q,"added_targets":["flat:f2"],"removed_targets":["flat:f1"]},{"foreign_id":"bob:234","time":%q,"new_targets":["flat:f1","flat:f2"]}]`, now.Format(stream.TimeLayout), now.Format(stream.TimeLayout)) 272 | testRequest(t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/feed_targets/flat/123/activity_to_targets/?api_key=key", body) 273 | 274 | reqs = append(reqs, stream.UpdateToTargetsRequest{ 275 | ForeignID: "bob:345", 276 | Time: now, 277 | }) 278 | 279 | // this should error since we have not specified any changes to the last request 280 | _, err = flat.BatchUpdateToTargets(ctx, reqs) 281 | require.Error(t, err) 282 | } 283 | 284 | func TestRealtimeToken(t *testing.T) { 285 | client, err := stream.New("key", "super secret") 286 | require.NoError(t, err) 287 | flat, _ := newFlatFeedWithUserID(client, "sample") 288 | testCases := []struct { 289 | readOnly bool 290 | expected string 291 | }{ 292 | { 293 | readOnly: false, 294 | expected: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpb24iOiJ3cml0ZSIsImZlZWRfaWQiOiJmbGF0c2FtcGxlIiwicmVzb3VyY2UiOiJmZWVkIn0._7eLZ3-_6dmOoCKp8MvSoKCp0PA-gAerKnr8tuwut2M", 295 | }, 296 | { 297 | readOnly: true, 298 | expected: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpb24iOiJyZWFkIiwiZmVlZF9pZCI6ImZsYXRzYW1wbGUiLCJyZXNvdXJjZSI6ImZlZWQifQ.Ab6NX3dAGbBiXkQrEIWg9Z-WRm1R4710ont2y0OONiE", 299 | }, 300 | } 301 | for _, tc := range testCases { 302 | token := flat.RealtimeToken(tc.readOnly) 303 | assert.Equal(t, tc.expected, token) 304 | } 305 | } 306 | 307 | func TestRemoveActivityByIDWithUserID(t *testing.T) { 308 | ctx := context.Background() 309 | client, requester := newClient(t) 310 | flat, _ := newFlatFeedWithUserID(client, "123") 311 | 312 | _, err := flat.RemoveActivityByID(ctx, "activity-id", stream.WithRemoveByUserID("user-id")) 313 | require.NoError(t, err) 314 | 315 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/activity-id/?api_key=key&user_id=user-id", "") 316 | } 317 | -------------------------------------------------------------------------------- /flat_feed.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | // FlatFeed is a Stream flat feed. 9 | type FlatFeed struct { 10 | feed 11 | } 12 | 13 | // GetActivities returns the activities for the given FlatFeed, filtering 14 | // results with the provided GetActivitiesOption parameters. 15 | func (f *FlatFeed) GetActivities(ctx context.Context, opts ...GetActivitiesOption) (*FlatFeedResponse, error) { 16 | body, err := f.client.getActivities(ctx, f, opts...) 17 | if err != nil { 18 | return nil, err 19 | } 20 | var resp FlatFeedResponse 21 | if err := json.Unmarshal(body, &resp); err != nil { 22 | return nil, err 23 | } 24 | return &resp, nil 25 | } 26 | 27 | // GetNextPageActivities returns the activities for the given FlatFeed at the "next" page 28 | // of a previous *FlatFeedResponse response, if any. 29 | func (f *FlatFeed) GetNextPageActivities(ctx context.Context, resp *FlatFeedResponse) (*FlatFeedResponse, error) { 30 | opts, err := resp.parseNext() 31 | if err != nil { 32 | return nil, err 33 | } 34 | return f.GetActivities(ctx, opts...) 35 | } 36 | 37 | // GetActivitiesWithRanking returns the activities (filtered) for the given FlatFeed, 38 | // using the provided ranking method. 39 | func (f *FlatFeed) GetActivitiesWithRanking(ctx context.Context, ranking string, opts ...GetActivitiesOption) (*FlatFeedResponse, error) { 40 | return f.GetActivities(ctx, append(opts, WithActivitiesRanking(ranking))...) 41 | } 42 | 43 | // GetFollowers returns the feeds following the given FlatFeed. 44 | func (f *FlatFeed) GetFollowers(ctx context.Context, opts ...FollowersOption) (*FollowersResponse, error) { 45 | return f.client.getFollowers(ctx, f, opts...) 46 | } 47 | 48 | // GetEnrichedActivities returns the enriched activities for the given FlatFeed, filtering 49 | // results with the provided GetActivitiesOption parameters. 50 | func (f *FlatFeed) GetEnrichedActivities(ctx context.Context, opts ...GetActivitiesOption) (*EnrichedFlatFeedResponse, error) { 51 | body, err := f.client.getEnrichedActivities(ctx, f, opts...) 52 | if err != nil { 53 | return nil, err 54 | } 55 | var resp EnrichedFlatFeedResponse 56 | if err := json.Unmarshal(body, &resp); err != nil { 57 | return nil, err 58 | } 59 | return &resp, nil 60 | } 61 | 62 | // GetNextPageEnrichedActivities returns the enriched activities for the given FlatFeed at the "next" page 63 | // of a previous *EnrichedFlatFeedResponse response, if any. 64 | func (f *FlatFeed) GetNextPageEnrichedActivities(ctx context.Context, resp *EnrichedFlatFeedResponse) (*EnrichedFlatFeedResponse, error) { 65 | opts, err := resp.parseNext() 66 | if err != nil { 67 | return nil, err 68 | } 69 | return f.GetEnrichedActivities(ctx, opts...) 70 | } 71 | 72 | // GetEnrichedActivitiesWithRanking returns the enriched activities (filtered) for the given FlatFeed, 73 | // using the provided ranking method. 74 | func (f *FlatFeed) GetEnrichedActivitiesWithRanking(ctx context.Context, ranking string, opts ...GetActivitiesOption) (*EnrichedFlatFeedResponse, error) { 75 | return f.GetEnrichedActivities(ctx, append(opts, WithActivitiesRanking(ranking))...) 76 | } 77 | 78 | // FollowStats returns the follower/following counts of the feed. 79 | // If options are given, counts are filtered for the given slugs. 80 | // Counts will be capped at 10K, if higher counts are needed and contact to support. 81 | func (f *FlatFeed) FollowStats(ctx context.Context, opts ...FollowStatOption) (*FollowStatResponse, error) { 82 | return f.client.followStats(ctx, f, opts...) 83 | } 84 | -------------------------------------------------------------------------------- /flat_feed_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | stream "github.com/GetStream/stream-go2/v8" 14 | ) 15 | 16 | func TestFlatFeedGetActivities(t *testing.T) { 17 | ctx := context.Background() 18 | client, requester := newClient(t) 19 | flat, _ := newFlatFeedWithUserID(client, "123") 20 | testCases := []struct { 21 | opts []stream.GetActivitiesOption 22 | url string 23 | enrichedURL string 24 | }{ 25 | { 26 | url: "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key", 27 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/flat/123/?api_key=key", 28 | }, 29 | { 30 | opts: []stream.GetActivitiesOption{stream.WithActivitiesLimit(42)}, 31 | url: "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key&limit=42", 32 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/flat/123/?api_key=key&limit=42", 33 | }, 34 | { 35 | opts: []stream.GetActivitiesOption{ 36 | stream.WithActivitiesLimit(42), 37 | stream.WithActivitiesOffset(11), 38 | stream.WithActivitiesIDGT("aabbcc"), 39 | stream.WithActivitiesIDGTE("ccddee"), 40 | stream.WithActivitiesIDLT("ffgghh"), 41 | stream.WithActivitiesIDLTE("iijjkk"), 42 | }, 43 | url: "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key&id_gt=aabbcc&id_gte=ccddee&id_lt=ffgghh&id_lte=iijjkk&limit=42&offset=11", 44 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/flat/123/?api_key=key&id_gt=aabbcc&id_gte=ccddee&id_lt=ffgghh&id_lte=iijjkk&limit=42&offset=11", 45 | }, 46 | { 47 | opts: []stream.GetActivitiesOption{ 48 | stream.WithCustomParam("aaa", "bbb"), 49 | }, 50 | url: "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?aaa=bbb&api_key=key", 51 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/flat/123/?aaa=bbb&api_key=key", 52 | }, 53 | } 54 | 55 | for _, tc := range testCases { 56 | _, err := flat.GetActivities(ctx, tc.opts...) 57 | testRequest(t, requester.req, http.MethodGet, tc.url, "") 58 | assert.NoError(t, err) 59 | 60 | _, err = flat.GetActivitiesWithRanking(ctx, "popularity", tc.opts...) 61 | testRequest(t, requester.req, http.MethodGet, fmt.Sprintf("%s&ranking=popularity", tc.url), "") 62 | assert.NoError(t, err) 63 | 64 | _, err = flat.GetEnrichedActivities(ctx, tc.opts...) 65 | testRequest(t, requester.req, http.MethodGet, tc.enrichedURL, "") 66 | assert.NoError(t, err) 67 | 68 | _, err = flat.GetEnrichedActivitiesWithRanking(ctx, "popularity", tc.opts...) 69 | testRequest(t, requester.req, http.MethodGet, fmt.Sprintf("%s&ranking=popularity", tc.enrichedURL), "") 70 | assert.NoError(t, err) 71 | } 72 | } 73 | 74 | func TestFlatFeedGetActivitiesExternalRanking(t *testing.T) { 75 | ctx := context.Background() 76 | client, requester := newClient(t) 77 | flat, _ := newFlatFeedWithUserID(client, "123") 78 | 79 | externalVarJSON, err := json.Marshal(map[string]any{ 80 | "music": 1, 81 | "sports": 2.1, 82 | "boolVal": true, 83 | "string": "str", 84 | }) 85 | 86 | require.NoError(t, err) 87 | testCases := []struct { 88 | opts []stream.GetActivitiesOption 89 | url string 90 | enrichedURL string 91 | }{ 92 | { 93 | opts: []stream.GetActivitiesOption{ 94 | stream.WithExternalRankingVars(string(externalVarJSON)), 95 | }, 96 | url: "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key&ranking_vars=%7B%22boolVal%22%3Atrue%2C%22music%22%3A1%2C%22sports%22%3A2.1%2C%22string%22%3A%22str%22%7D", 97 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/flat/123/?api_key=key&ranking_vars=%7B%22boolVal%22%3Atrue%2C%22music%22%3A1%2C%22sports%22%3A2.1%2C%22string%22%3A%22str%22%7D", 98 | }, 99 | } 100 | 101 | for _, tc := range testCases { 102 | _, err := flat.GetActivities(ctx, tc.opts...) 103 | testRequest(t, requester.req, http.MethodGet, tc.url, "") 104 | assert.NoError(t, err) 105 | } 106 | } 107 | 108 | func TestFlatFeedGetNextPageActivities(t *testing.T) { 109 | ctx := context.Background() 110 | client, requester := newClient(t) 111 | flat, _ := newFlatFeedWithUserID(client, "123") 112 | 113 | requester.resp = `{"next":"/api/v1.0/feed/flat/123/?id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25"}` 114 | resp, err := flat.GetActivities(ctx) 115 | require.NoError(t, err) 116 | 117 | _, err = flat.GetNextPageActivities(ctx, resp) 118 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/?api_key=key&id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25", "") 119 | require.NoError(t, err) 120 | 121 | requester.resp = `{"next":"/api/v1.0/enrich/feed/flat/123/?id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25"}` 122 | enrichedResp, err := flat.GetEnrichedActivities(ctx) 123 | require.NoError(t, err) 124 | 125 | _, err = flat.GetNextPageEnrichedActivities(ctx, enrichedResp) 126 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/enrich/feed/flat/123/?api_key=key&id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25", "") 127 | require.NoError(t, err) 128 | 129 | requester.resp = `{"next":123}` 130 | _, err = flat.GetActivities(ctx) 131 | require.Error(t, err) 132 | 133 | requester.resp = `{"next":"123"}` 134 | resp, err = flat.GetActivities(ctx) 135 | require.NoError(t, err) 136 | _, err = flat.GetNextPageActivities(ctx, resp) 137 | require.Error(t, err) 138 | 139 | requester.resp = `{"next":"?q=a%"}` 140 | resp, err = flat.GetActivities(ctx) 141 | require.NoError(t, err) 142 | _, err = flat.GetNextPageActivities(ctx, resp) 143 | require.Error(t, err) 144 | } 145 | 146 | func TestFlatFeedFollowStats(t *testing.T) { 147 | ctx := context.Background() 148 | endpoint := "https://api.stream-io-api.com/api/v1.0/stats/follow/?api_key=key" 149 | 150 | client, requester := newClient(t) 151 | flat, _ := newFlatFeedWithUserID(client, "123") 152 | 153 | _, err := flat.FollowStats(ctx) 154 | testRequest(t, requester.req, http.MethodGet, endpoint+"&followers=flat%3A123&following=flat%3A123", "") 155 | assert.NoError(t, err) 156 | 157 | _, err = flat.FollowStats(ctx, stream.WithFollowerSlugs("a", "b")) 158 | testRequest(t, requester.req, http.MethodGet, endpoint+"&followers=flat%3A123&followers_slugs=a%2Cb&following=flat%3A123", "") 159 | assert.NoError(t, err) 160 | 161 | _, err = flat.FollowStats(ctx, stream.WithFollowingSlugs("c", "d")) 162 | testRequest(t, requester.req, http.MethodGet, endpoint+"&followers=flat%3A123&following=flat%3A123&following_slugs=c%2Cd", "") 163 | assert.NoError(t, err) 164 | 165 | _, err = flat.FollowStats(ctx, stream.WithFollowingSlugs("c", "d"), stream.WithFollowerSlugs("a", "b")) 166 | testRequest(t, requester.req, http.MethodGet, endpoint+"&followers=flat%3A123&followers_slugs=a%2Cb&following=flat%3A123&following_slugs=c%2Cd", "") 167 | assert.NoError(t, err) 168 | } 169 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/GetStream/stream-go2/v8 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/fatih/structs v1.1.0 7 | github.com/golang-jwt/jwt/v4 v4.5.0 8 | github.com/mitchellh/mapstructure v1.5.0 9 | github.com/stretchr/testify v1.7.1 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= 5 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 6 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 7 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 8 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 9 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 14 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= 19 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | -------------------------------------------------------------------------------- /moderation.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | ModerationActivity = "stream:feeds:v2:activity" 10 | ModerationReaction = "stream:feeds:v2:reaction" 11 | ModerationUser = "stream:user" 12 | ) 13 | 14 | type ModerationClient struct { 15 | client *Client 16 | } 17 | 18 | type flagRequest struct { 19 | // the user doing the reporting 20 | UserID string `json:"user_id"` 21 | EntityType string `json:"entity_type"` 22 | EntityID string `json:"entity_id"` 23 | Reason string `json:"reason"` 24 | } 25 | 26 | func (c *ModerationClient) FlagActivity(ctx context.Context, userID, activityID, reason string) error { 27 | r := flagRequest{ 28 | UserID: userID, 29 | EntityType: ModerationActivity, 30 | EntityID: activityID, 31 | Reason: reason, 32 | } 33 | return c.flagContent(ctx, r) 34 | } 35 | 36 | func (c *ModerationClient) FlagReaction(ctx context.Context, userID, reactionID, reason string) error { 37 | r := flagRequest{ 38 | UserID: userID, 39 | EntityType: ModerationReaction, 40 | EntityID: reactionID, 41 | Reason: reason, 42 | } 43 | return c.flagContent(ctx, r) 44 | } 45 | 46 | func (c *ModerationClient) FlagUser(ctx context.Context, userID, targetUserID, reason string) error { 47 | r := flagRequest{ 48 | UserID: userID, 49 | EntityType: ModerationUser, 50 | EntityID: targetUserID, 51 | Reason: reason, 52 | } 53 | return c.flagContent(ctx, r) 54 | } 55 | 56 | func (c *ModerationClient) flagContent(ctx context.Context, r flagRequest) error { 57 | endpoint := c.client.makeEndpoint("moderation/flag/") 58 | 59 | _, err := c.client.post(ctx, endpoint, r, c.client.authenticator.moderationAuth) 60 | return err 61 | } 62 | 63 | type updateStatusRequest struct { 64 | EntityType string `json:"entity_type"` 65 | EntityID string `json:"entity_id"` 66 | ModeratorID string `json:"moderator_id"` 67 | Status string `json:"status"` 68 | RecommendedAction string `json:"recommended_action"` 69 | LatestModeratorAction string `json:"latest_moderator_action"` 70 | } 71 | 72 | func (c *ModerationClient) UpdateActivityModerationStatus(ctx context.Context, activityID, modID, status, recAction, modAction string) error { 73 | r := updateStatusRequest{ 74 | EntityType: ModerationActivity, 75 | EntityID: activityID, 76 | ModeratorID: modID, 77 | Status: status, 78 | RecommendedAction: recAction, 79 | LatestModeratorAction: modAction, 80 | } 81 | return c.updateStatus(ctx, r) 82 | } 83 | 84 | func (c *ModerationClient) UpdateReactionModerationStatus(ctx context.Context, reactionID, modID, status, recAction, modAction string) error { 85 | r := updateStatusRequest{ 86 | EntityType: ModerationReaction, 87 | EntityID: reactionID, 88 | ModeratorID: modID, 89 | Status: status, 90 | RecommendedAction: recAction, 91 | LatestModeratorAction: modAction, 92 | } 93 | return c.updateStatus(ctx, r) 94 | } 95 | 96 | func (c *ModerationClient) updateStatus(ctx context.Context, r updateStatusRequest) error { 97 | endpoint := c.client.makeEndpoint("moderation/status/") 98 | 99 | _, err := c.client.post(ctx, endpoint, r, c.client.authenticator.moderationAuth) 100 | return err 101 | } 102 | 103 | func (c *ModerationClient) InvalidateUserCache(ctx context.Context, userID string) error { 104 | if userID == "" { 105 | return fmt.Errorf("empty userID") 106 | } 107 | 108 | endpoint := c.client.makeEndpoint("moderation/user/cache/%s/", userID) 109 | 110 | _, err := c.client.delete(ctx, endpoint, nil, c.client.authenticator.moderationAuth) 111 | return err 112 | } 113 | -------------------------------------------------------------------------------- /moderation_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestFlagActivity(t *testing.T) { 12 | ctx := context.Background() 13 | client, requester := newClient(t) 14 | err := client.Moderation().FlagActivity(ctx, "jimmy", "foo", "reason1") 15 | require.NoError(t, err) 16 | testRequest( 17 | t, 18 | requester.req, 19 | http.MethodPost, 20 | "https://api.stream-io-api.com/api/v1.0/moderation/flag/?api_key=key", 21 | `{"entity_id":"foo", "entity_type":"stream:feeds:v2:activity", "reason":"reason1", "user_id":"jimmy"}`, 22 | ) 23 | } 24 | 25 | func TestFlagReaction(t *testing.T) { 26 | ctx := context.Background() 27 | client, requester := newClient(t) 28 | err := client.Moderation().FlagReaction(ctx, "jimmy", "foo", "reason1") 29 | require.NoError(t, err) 30 | testRequest( 31 | t, 32 | requester.req, 33 | http.MethodPost, 34 | "https://api.stream-io-api.com/api/v1.0/moderation/flag/?api_key=key", 35 | `{"entity_id":"foo", "entity_type":"stream:feeds:v2:reaction", "reason":"reason1", "user_id":"jimmy"}`, 36 | ) 37 | } 38 | 39 | func TestFlagUser(t *testing.T) { 40 | ctx := context.Background() 41 | client, requester := newClient(t) 42 | err := client.Moderation().FlagUser(ctx, "jimmy", "foo", "reason1") 43 | require.NoError(t, err) 44 | testRequest( 45 | t, 46 | requester.req, 47 | http.MethodPost, 48 | "https://api.stream-io-api.com/api/v1.0/moderation/flag/?api_key=key", 49 | `{"entity_id":"foo", "entity_type":"stream:user", "reason":"reason1", "user_id":"jimmy"}`, 50 | ) 51 | } 52 | 53 | func TestUpdateActivityModerationStatus(t *testing.T) { 54 | ctx := context.Background() 55 | client, requester := newClient(t) 56 | err := client.Moderation().UpdateActivityModerationStatus(ctx, "foo", "moderator_123", "complete", "watch", "mark_safe") 57 | require.NoError(t, err) 58 | testRequest( 59 | t, 60 | requester.req, 61 | http.MethodPost, 62 | "https://api.stream-io-api.com/api/v1.0/moderation/status/?api_key=key", 63 | `{"entity_id":"foo", "entity_type":"stream:feeds:v2:activity", "moderator_id": "moderator_123", "latest_moderator_action":"mark_safe", "recommended_action":"watch", "status":"complete"}`, 64 | ) 65 | } 66 | 67 | func TestUpdateReactionModerationStatus(t *testing.T) { 68 | ctx := context.Background() 69 | client, requester := newClient(t) 70 | err := client.Moderation().UpdateReactionModerationStatus(ctx, "foo", "moderator_123", "complete", "watch", "mark_safe") 71 | require.NoError(t, err) 72 | testRequest( 73 | t, 74 | requester.req, 75 | http.MethodPost, 76 | "https://api.stream-io-api.com/api/v1.0/moderation/status/?api_key=key", 77 | `{"entity_id":"foo", "entity_type":"stream:feeds:v2:reaction", "moderator_id": "moderator_123", "latest_moderator_action":"mark_safe", "recommended_action":"watch", "status":"complete"}`, 78 | ) 79 | } 80 | 81 | func TestInvalidateUserCache(t *testing.T) { 82 | ctx := context.Background() 83 | client, requester := newClient(t) 84 | err := client.Moderation().InvalidateUserCache(ctx, "foo") 85 | require.NoError(t, err) 86 | testRequest( 87 | t, 88 | requester.req, 89 | http.MethodDelete, 90 | "https://api.stream-io-api.com/api/v1.0/moderation/user/cache/foo/?api_key=key", 91 | "", 92 | ) 93 | 94 | err = client.Moderation().InvalidateUserCache(ctx, "") 95 | require.Error(t, err) 96 | require.Equal(t, "empty userID", err.Error()) 97 | } 98 | -------------------------------------------------------------------------------- /notification_feed.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | // NotificationFeed is a Stream notification feed. 9 | type NotificationFeed struct { 10 | feed 11 | } 12 | 13 | // GetActivities returns the activities for the given NotificationFeed, filtering 14 | // results with the provided GetActivitiesOption parameters. 15 | func (f *NotificationFeed) GetActivities(ctx context.Context, opts ...GetActivitiesOption) (*NotificationFeedResponse, error) { 16 | body, err := f.client.getActivities(ctx, f, opts...) 17 | if err != nil { 18 | return nil, err 19 | } 20 | var resp NotificationFeedResponse 21 | if err := json.Unmarshal(body, &resp); err != nil { 22 | return nil, err 23 | } 24 | return &resp, nil 25 | } 26 | 27 | // GetNextPageActivities returns the activities for the given NotificationFeed at the "next" page 28 | // of a previous *NotificationFeedResponse response, if any. 29 | func (f *NotificationFeed) GetNextPageActivities(ctx context.Context, resp *NotificationFeedResponse) (*NotificationFeedResponse, error) { 30 | opts, err := resp.parseNext() 31 | if err != nil { 32 | return nil, err 33 | } 34 | return f.GetActivities(ctx, opts...) 35 | } 36 | 37 | // GetEnrichedActivities returns the enriched activities for the given NotificationFeed, filtering 38 | // results with the provided GetActivitiesOption parameters. 39 | func (f *NotificationFeed) GetEnrichedActivities(ctx context.Context, opts ...GetActivitiesOption) (*EnrichedNotificationFeedResponse, error) { 40 | body, err := f.client.getEnrichedActivities(ctx, f, opts...) 41 | if err != nil { 42 | return nil, err 43 | } 44 | var resp EnrichedNotificationFeedResponse 45 | if err := json.Unmarshal(body, &resp); err != nil { 46 | return nil, err 47 | } 48 | return &resp, nil 49 | } 50 | 51 | // GetNextPageEnrichedActivities returns the enriched activities for the given NotificationFeed at the "next" page 52 | // of a previous *EnrichedNotificationFeedResponse response, if any. 53 | func (f *NotificationFeed) GetNextPageEnrichedActivities(ctx context.Context, resp *EnrichedNotificationFeedResponse) (*EnrichedNotificationFeedResponse, error) { 54 | opts, err := resp.parseNext() 55 | if err != nil { 56 | return nil, err 57 | } 58 | return f.GetEnrichedActivities(ctx, opts...) 59 | } 60 | -------------------------------------------------------------------------------- /notification_feed_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | stream "github.com/GetStream/stream-go2/v8" 12 | ) 13 | 14 | func TestGetNotificationActivities(t *testing.T) { 15 | ctx := context.Background() 16 | client, requester := newClient(t) 17 | notification, _ := newNotificationFeedWithUserID(client, "123") 18 | testCases := []struct { 19 | opts []stream.GetActivitiesOption 20 | url string 21 | enrichedURL string 22 | }{ 23 | { 24 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key", 25 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key", 26 | }, 27 | { 28 | opts: []stream.GetActivitiesOption{stream.WithActivitiesLimit(42)}, 29 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&limit=42", 30 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&limit=42", 31 | }, 32 | { 33 | opts: []stream.GetActivitiesOption{stream.WithActivitiesLimit(42), stream.WithActivitiesOffset(11), stream.WithActivitiesIDGT("aabbcc")}, 34 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&id_gt=aabbcc&limit=42&offset=11", 35 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&id_gt=aabbcc&limit=42&offset=11", 36 | }, 37 | { 38 | opts: []stream.GetActivitiesOption{stream.WithNotificationsMarkRead(false, "f1", "f2", "f3")}, 39 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&mark_read=f1%2Cf2%2Cf3", 40 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&mark_read=f1%2Cf2%2Cf3", 41 | }, 42 | { 43 | opts: []stream.GetActivitiesOption{stream.WithNotificationsMarkRead(true, "f1", "f2", "f3")}, 44 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&mark_read=true", 45 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&mark_read=true", 46 | }, 47 | { 48 | opts: []stream.GetActivitiesOption{stream.WithNotificationsMarkSeen(false, "f1", "f2", "f3")}, 49 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&mark_seen=f1%2Cf2%2Cf3", 50 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&mark_seen=f1%2Cf2%2Cf3", 51 | }, 52 | { 53 | opts: []stream.GetActivitiesOption{stream.WithNotificationsMarkSeen(true, "f1", "f2", "f3")}, 54 | url: "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&mark_seen=true", 55 | enrichedURL: "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&mark_seen=true", 56 | }, 57 | } 58 | 59 | for _, tc := range testCases { 60 | _, err := notification.GetActivities(ctx, tc.opts...) 61 | testRequest(t, requester.req, http.MethodGet, tc.url, "") 62 | assert.NoError(t, err) 63 | 64 | _, err = notification.GetEnrichedActivities(ctx, tc.opts...) 65 | testRequest(t, requester.req, http.MethodGet, tc.enrichedURL, "") 66 | assert.NoError(t, err) 67 | } 68 | } 69 | 70 | func TestNotificationFeedGetNextPageActivities(t *testing.T) { 71 | ctx := context.Background() 72 | client, requester := newClient(t) 73 | notification, _ := newNotificationFeedWithUserID(client, "123") 74 | 75 | requester.resp = `{"next":"/api/v1.0/feed/notification/123/?id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25"}` 76 | resp, err := notification.GetActivities(ctx) 77 | require.NoError(t, err) 78 | 79 | _, err = notification.GetNextPageActivities(ctx, resp) 80 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/feed/notification/123/?api_key=key&id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25", "") 81 | require.NoError(t, err) 82 | 83 | requester.resp = `{"next":"/api/v1.0/enrich/feed/notification/123/?id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25"}` 84 | enrichedResp, err := notification.GetEnrichedActivities(ctx) 85 | require.NoError(t, err) 86 | 87 | _, err = notification.GetNextPageEnrichedActivities(ctx, enrichedResp) 88 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/enrich/feed/notification/123/?api_key=key&id_lt=78c1a709-aff2-11e7-b3a7-a45e60be7d3b&limit=25", "") 89 | require.NoError(t, err) 90 | 91 | requester.resp = `{"next":123}` 92 | _, err = notification.GetActivities(ctx) 93 | require.Error(t, err) 94 | 95 | requester.resp = `{"next":"123"}` 96 | resp, err = notification.GetActivities(ctx) 97 | require.NoError(t, err) 98 | _, err = notification.GetNextPageActivities(ctx, resp) 99 | require.Error(t, err) 100 | 101 | requester.resp = `{"next":"?q=a%"}` 102 | resp, err = notification.GetActivities(ctx) 103 | require.NoError(t, err) 104 | _, err = notification.GetNextPageActivities(ctx, resp) 105 | require.Error(t, err) 106 | } 107 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | defaultActivityCopyLimit = 300 10 | ) 11 | 12 | // requestOption is an interface representing API request optional filters and 13 | // parameters. 14 | type requestOption interface { 15 | valuer 16 | } 17 | 18 | type valuer interface { 19 | values() (string, string) 20 | valid() bool 21 | } 22 | 23 | type baseRequestOption struct { 24 | key string 25 | value string 26 | } 27 | 28 | func makeRequestOption(key string, value any) requestOption { 29 | return baseRequestOption{ 30 | key: key, 31 | value: fmt.Sprintf("%v", value), 32 | } 33 | } 34 | 35 | func (o baseRequestOption) values() (key, value string) { 36 | return o.key, o.value 37 | } 38 | 39 | func (o baseRequestOption) valid() bool { 40 | return o.value != "" 41 | } 42 | 43 | func withLimit(limit int) requestOption { 44 | return makeRequestOption("limit", limit) 45 | } 46 | 47 | func withOffset(offset int) requestOption { 48 | return makeRequestOption("offset", offset) 49 | } 50 | 51 | // GetActivitiesOption is an option usable by GetActivities methods for flat and aggregated feeds. 52 | type GetActivitiesOption struct { 53 | requestOption 54 | } 55 | 56 | // WithActivitiesLimit adds the limit parameter to API calls which support it, limiting 57 | // the number of results in the response to the provided limit threshold. 58 | // Supported operations: retrieve activities, retrieve followers, retrieve 59 | // following. 60 | func WithActivitiesLimit(limit int) GetActivitiesOption { 61 | return GetActivitiesOption{withLimit(limit)} 62 | } 63 | 64 | // WithActivitiesOffset adds the offset parameter to API calls which support it, getting 65 | // results starting from the provided offset index. 66 | // Supported operations: retrieve activities, retrieve followers, retrieve 67 | // following. 68 | func WithActivitiesOffset(offset int) GetActivitiesOption { 69 | return GetActivitiesOption{withOffset(offset)} 70 | } 71 | 72 | // WithActivitiesIDGTE adds the id_gte parameter to API calls, used when retrieving 73 | // paginated activities from feeds, returning activities with ID greater or 74 | // equal than the provided id. 75 | func WithActivitiesIDGTE(id string) GetActivitiesOption { 76 | return GetActivitiesOption{makeRequestOption("id_gte", id)} 77 | } 78 | 79 | // WithActivitiesIDGT adds the id_gt parameter to API calls, used when retrieving 80 | // paginated activities from feeds, returning activities with ID greater than 81 | // the provided id. 82 | func WithActivitiesIDGT(id string) GetActivitiesOption { 83 | return GetActivitiesOption{makeRequestOption("id_gt", id)} 84 | } 85 | 86 | // WithActivitiesIDLTE adds the id_lte parameter to API calls, used when retrieving 87 | // paginated activities from feeds, returning activities with ID lesser or equal 88 | // than the provided id. 89 | func WithActivitiesIDLTE(id string) GetActivitiesOption { 90 | return GetActivitiesOption{makeRequestOption("id_lte", id)} 91 | } 92 | 93 | // WithActivitiesIDLT adds the id_lt parameter to API calls, used when retrieving 94 | // paginated activities from feeds, returning activities with ID lesser than the 95 | // provided id. 96 | func WithActivitiesIDLT(id string) GetActivitiesOption { 97 | return GetActivitiesOption{makeRequestOption("id_lt", id)} 98 | } 99 | 100 | func WithActivitiesRanking(ranking string) GetActivitiesOption { 101 | return GetActivitiesOption{makeRequestOption("ranking", ranking)} 102 | } 103 | 104 | func WithRankingScoreVars() GetActivitiesOption { 105 | return GetActivitiesOption{makeRequestOption("withScoreVars", true)} 106 | } 107 | 108 | // externalVarJSON should be valid json 109 | func WithExternalRankingVars(externalVarJSON string) GetActivitiesOption { 110 | return GetActivitiesOption{makeRequestOption("ranking_vars", externalVarJSON)} 111 | } 112 | 113 | // WithNotificationsMarkSeen marks as seen the given activity ids in a notification 114 | // feed. If the all parameter is true, every activity in the feed is marked as seen. 115 | func WithNotificationsMarkSeen(all bool, activityIDs ...string) GetActivitiesOption { 116 | if all { 117 | return GetActivitiesOption{makeRequestOption("mark_seen", true)} 118 | } 119 | return GetActivitiesOption{makeRequestOption("mark_seen", strings.Join(activityIDs, ","))} 120 | } 121 | 122 | // WithNotificationsMarkRead marks as read the given activity ids in a notification 123 | // feed. If the all parameter is true, every activity in the feed is marked as read. 124 | func WithNotificationsMarkRead(all bool, activityIDs ...string) GetActivitiesOption { 125 | if all { 126 | return GetActivitiesOption{makeRequestOption("mark_read", true)} 127 | } 128 | return GetActivitiesOption{makeRequestOption("mark_read", strings.Join(activityIDs, ","))} 129 | } 130 | 131 | // WithCustomParam adds a custom parameter to the read request. 132 | func WithCustomParam(name, value string) GetActivitiesOption { 133 | return GetActivitiesOption{makeRequestOption(name, value)} 134 | } 135 | 136 | // WithEnrichOwnReactions enriches the activities with the reactions to them. 137 | func WithEnrichOwnReactions() GetActivitiesOption { 138 | return GetActivitiesOption{makeRequestOption("withOwnReactions", true)} 139 | } 140 | 141 | func WithEnrichUserReactions(userID string) GetActivitiesOption { 142 | return GetActivitiesOption{makeRequestOption("user_id", userID)} 143 | } 144 | 145 | // WithEnrichRecentReactions enriches the activities with the first reactions to them. 146 | func WithEnrichFirstReactions() GetActivitiesOption { 147 | return GetActivitiesOption{makeRequestOption("withFirstReactions", true)} 148 | } 149 | 150 | // WithEnrichRecentReactions enriches the activities with the recent reactions to them. 151 | func WithEnrichRecentReactions() GetActivitiesOption { 152 | return GetActivitiesOption{makeRequestOption("withRecentReactions", true)} 153 | } 154 | 155 | // WithEnrichReactionCounts enriches the activities with the reaction counts. 156 | func WithEnrichReactionCounts() GetActivitiesOption { 157 | return GetActivitiesOption{makeRequestOption("withReactionCounts", true)} 158 | } 159 | 160 | // WithEnrichOwnChildren enriches the activities with the children reactions. 161 | func WithEnrichOwnChildren() GetActivitiesOption { 162 | return GetActivitiesOption{makeRequestOption("withOwnChildren", true)} 163 | } 164 | 165 | // WithEnrichRecentReactionsLimit specifies how many recent reactions to include in the enrichment. 166 | func WithEnrichRecentReactionsLimit(limit int) GetActivitiesOption { 167 | return GetActivitiesOption{makeRequestOption("recentReactionsLimit", limit)} 168 | } 169 | 170 | // WithEnrichReactionsLimit specifies how many reactions to include in the enrichment. 171 | func WithEnrichReactionsLimit(limit int) GetActivitiesOption { 172 | return GetActivitiesOption{makeRequestOption("reaction_limit", limit)} 173 | } 174 | 175 | // WithEnrichReactionKindsFilter filters the reactions by the specified kinds 176 | func WithEnrichReactionKindsFilter(kinds ...string) GetActivitiesOption { 177 | return GetActivitiesOption{makeRequestOption("reactionKindsFilter", strings.Join(kinds, ","))} 178 | } 179 | 180 | // WithEnrichOwnChildrenKindsFilter filters the reactions by the specified kinds for own children 181 | func WithEnrichOwnChildrenKindsFilter(kinds ...string) GetActivitiesOption { 182 | return GetActivitiesOption{makeRequestOption("withOwnChildrenKinds", strings.Join(kinds, ","))} 183 | } 184 | 185 | // FollowingOption is an option usable by following feed methods. 186 | type FollowingOption struct { 187 | requestOption 188 | } 189 | 190 | // WithFollowingFilter adds the filter parameter to API calls, used when retrieving 191 | // following feeds, allowing the check whether certain feeds are being followed. 192 | func WithFollowingFilter(ids ...string) FollowingOption { 193 | return FollowingOption{makeRequestOption("filter", strings.Join(ids, ","))} 194 | } 195 | 196 | // WithFollowingLimit limits the number of followings in the response to the provided limit. 197 | func WithFollowingLimit(limit int) FollowingOption { 198 | return FollowingOption{withLimit(limit)} 199 | } 200 | 201 | // WithFollowingOffset returns followings starting from the given offset. 202 | func WithFollowingOffset(offset int) FollowingOption { 203 | return FollowingOption{withOffset(offset)} 204 | } 205 | 206 | // FollowersOption is an option usable by followers feed methods. 207 | type FollowersOption struct { 208 | requestOption 209 | } 210 | 211 | // WithFollowersLimit limits the number of followers in the response to the provided limit. 212 | func WithFollowersLimit(limit int) FollowersOption { 213 | return FollowersOption{withLimit(limit)} 214 | } 215 | 216 | // WithFollowersOffset returns followers starting from the given offset. 217 | func WithFollowersOffset(offset int) FollowersOption { 218 | return FollowersOption{withOffset(offset)} 219 | } 220 | 221 | // UnfollowOption is an option usable with the Unfollow feed method. 222 | type UnfollowOption struct { 223 | requestOption 224 | } 225 | 226 | // WithUnfollowKeepHistory adds the keep_history parameter to API calls, used to keep 227 | // history when unfollowing feeds, rather than purging it (default behavior). 228 | // If the keepHistory parameter is false, nothing happens. 229 | func WithUnfollowKeepHistory(keepHistory bool) UnfollowOption { 230 | if !keepHistory { 231 | return UnfollowOption{nop{}} 232 | } 233 | return UnfollowOption{makeRequestOption("keep_history", 1)} 234 | } 235 | 236 | type followFeedOptions struct { 237 | Target string `json:"target,omitempty"` 238 | ActivityCopyLimit int `json:"activity_copy_limit"` 239 | } 240 | 241 | // FollowManyOption is an option to customize behavior of Follow Many calls. 242 | type FollowManyOption struct { 243 | requestOption 244 | } 245 | 246 | // WithFollowManyActivityCopyLimit sets how many activities should be copied from the target feed. 247 | func WithFollowManyActivityCopyLimit(activityCopyLimit int) FollowManyOption { 248 | return FollowManyOption{makeRequestOption("activity_copy_limit", activityCopyLimit)} 249 | } 250 | 251 | // FollowFeedOption is a function used to customize FollowFeed API calls. 252 | type FollowFeedOption func(*followFeedOptions) 253 | 254 | // WithFollowFeedActivityCopyLimit sets the activity copy threshold for Follow Feed API 255 | // calls. 256 | func WithFollowFeedActivityCopyLimit(activityCopyLimit int) FollowFeedOption { 257 | return func(o *followFeedOptions) { 258 | o.ActivityCopyLimit = activityCopyLimit 259 | } 260 | } 261 | 262 | // FollowStatOption is an option used to customize FollowStats API calls. 263 | type FollowStatOption struct { 264 | requestOption 265 | } 266 | 267 | // WithFollowerSlugs sets the follower feed slugs for filtering in counting. 268 | func WithFollowerSlugs(slugs ...string) FollowStatOption { 269 | return FollowStatOption{makeRequestOption("followers_slugs", strings.Join(slugs, ","))} 270 | } 271 | 272 | // WithFollowerSlugs sets the following feed slugs for filtering in counting. 273 | func WithFollowingSlugs(slugs ...string) FollowStatOption { 274 | return FollowStatOption{makeRequestOption("following_slugs", strings.Join(slugs, ","))} 275 | } 276 | 277 | // UpdateToTargetsOption determines what operations perform during an UpdateToTargets API call. 278 | type UpdateToTargetsOption func(*updateToTargetsRequest) 279 | 280 | // WithToTargetsNew sets the new to targets, replacing all the existing ones. It cannot be used in combination with any other UpdateToTargetsOption. 281 | func WithToTargetsNew(targets ...string) UpdateToTargetsOption { 282 | return func(r *updateToTargetsRequest) { 283 | r.New = targets 284 | } 285 | } 286 | 287 | // WithToTargetsAdd sets the add to targets, adding them to the activity's existing ones. 288 | func WithToTargetsAdd(targets ...string) UpdateToTargetsOption { 289 | return func(r *updateToTargetsRequest) { 290 | r.Adds = targets 291 | } 292 | } 293 | 294 | // WithToTargetsRemove sets the remove to targets, removing them from activity's the existing ones. 295 | func WithToTargetsRemove(targets ...string) UpdateToTargetsOption { 296 | return func(r *updateToTargetsRequest) { 297 | r.Removes = targets 298 | } 299 | } 300 | 301 | // AddObjectOption is an option usable by the Collections.Add method. 302 | type AddObjectOption func(*addCollectionRequest) 303 | 304 | // WithUserID adds the user id to the Collections.Add request object. 305 | func WithUserID(userID string) AddObjectOption { 306 | return func(req *addCollectionRequest) { 307 | req.UserID = &userID 308 | } 309 | } 310 | 311 | // FilterReactionsOption is an option used by Reactions.Filter() to support pagination. 312 | type FilterReactionsOption struct { 313 | requestOption 314 | } 315 | 316 | // WithLimit adds the limit parameter to the Reactions.Filter() call. 317 | func WithLimit(limit int) FilterReactionsOption { 318 | return FilterReactionsOption{withLimit(limit)} 319 | } 320 | 321 | // WithIDGTE adds the id_gte parameter to API calls, used when retrieving 322 | // paginated reactions, returning activities with ID greater or 323 | // equal than the provided id. 324 | func WithIDGTE(id string) FilterReactionsOption { 325 | return FilterReactionsOption{makeRequestOption("id_gte", id)} 326 | } 327 | 328 | // WithIDGT adds the id_gt parameter to API calls, used when retrieving 329 | // paginated reactions. 330 | func WithIDGT(id string) FilterReactionsOption { 331 | return FilterReactionsOption{makeRequestOption("id_gt", id)} 332 | } 333 | 334 | // WithIDLTE adds the id_lte parameter to API calls, used when retrieving 335 | // paginated reactions. 336 | func WithIDLTE(id string) FilterReactionsOption { 337 | return FilterReactionsOption{makeRequestOption("id_lte", id)} 338 | } 339 | 340 | // WithIDLT adds the id_lt parameter to API calls, used when retrieving 341 | // paginated reactions. 342 | func WithIDLT(id string) FilterReactionsOption { 343 | return FilterReactionsOption{makeRequestOption("id_lt", id)} 344 | } 345 | 346 | // WithActivityData will enable returning the activity data when filtering 347 | // reactions by activity_id. 348 | func WithActivityData() FilterReactionsOption { 349 | return FilterReactionsOption{makeRequestOption("with_activity_data", true)} 350 | } 351 | 352 | // WithOwnChildren will enable returning the children reactions when filtering 353 | // reactions by parent ID. 354 | func WithOwnChildren() FilterReactionsOption { 355 | return FilterReactionsOption{makeRequestOption("with_own_children", true)} 356 | } 357 | 358 | // WithOwnUserID will enable further filtering by the given user id. 359 | // It's similar to FilterReactionsAttribute user id. 360 | func WithOwnUserID(userID string) FilterReactionsOption { 361 | return FilterReactionsOption{makeRequestOption("user_id", userID)} 362 | } 363 | 364 | // WithChildrenUserID will enable further filtering own children by the given user id. 365 | // It's different than FilterReactionsAttribute user id. 366 | func WithChildrenUserID(userID string) FilterReactionsOption { 367 | return FilterReactionsOption{makeRequestOption("children_user_id", userID)} 368 | } 369 | 370 | // WithRanking adds the ranking parameter to API calls, used when retrieving 371 | // ranked reactions. 372 | func WithRanking(ranking string) FilterReactionsOption { 373 | return FilterReactionsOption{makeRequestOption("ranking", ranking)} 374 | } 375 | 376 | // FilterReactionsAttribute specifies the filtering method of Reactions.Filter() 377 | type FilterReactionsAttribute func() string 378 | 379 | // ByKind filters reactions by kind, after the initial desired filtering method was applied. 380 | func (a FilterReactionsAttribute) ByKind(kind string) FilterReactionsAttribute { 381 | return func() string { 382 | base := a() 383 | return fmt.Sprintf("%s/%s", base, kind) 384 | } 385 | } 386 | 387 | // ByActivityID will filter reactions based on the specified activity id. 388 | func ByActivityID(activityID string) FilterReactionsAttribute { 389 | return func() string { 390 | return fmt.Sprintf("activity_id/%s", activityID) 391 | } 392 | } 393 | 394 | // ByReactionID will filter reactions based on the specified parent reaction id. 395 | func ByReactionID(reactionID string) FilterReactionsAttribute { 396 | return func() string { 397 | return fmt.Sprintf("reaction_id/%s", reactionID) 398 | } 399 | } 400 | 401 | // ByUserID will filter reactions based on the specified user id. 402 | func ByUserID(userID string) FilterReactionsAttribute { 403 | return func() string { 404 | return fmt.Sprintf("user_id/%s", userID) 405 | } 406 | } 407 | 408 | type nop struct{} 409 | 410 | func (nop) values() (key, value string) { 411 | return "", "" 412 | } 413 | 414 | func (nop) valid() bool { 415 | return false 416 | } 417 | 418 | // RemoveActivityOption is an option usable by RemoveActivityByID method. 419 | type RemoveActivityOption struct { 420 | requestOption 421 | } 422 | 423 | // WithRemoveByUserID adds the user_id parameter to API calls, used when removing activities 424 | // to specify which user ID should be used. 425 | func WithRemoveByUserID(userID string) RemoveActivityOption { 426 | return RemoveActivityOption{makeRequestOption("user_id", userID)} 427 | } 428 | 429 | // ReactionOption is an option usable by Reactions methods. 430 | type ReactionOption struct { 431 | requestOption 432 | } 433 | 434 | // WithReactionUserID adds the user_id parameter to API calls, used when performing 435 | // reaction operations to specify which user ID should be used. 436 | func WithReactionUserID(userID string) ReactionOption { 437 | return ReactionOption{makeRequestOption("user_id", userID)} 438 | } 439 | 440 | type GetReactionsOption struct { 441 | requestOption 442 | } 443 | 444 | func WithReactionsIncludeDeleted() GetReactionsOption { 445 | return GetReactionsOption{makeRequestOption("include_deleted", true)} 446 | } 447 | -------------------------------------------------------------------------------- /personalization.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | // PersonalizationClient is a specialized client for personalization features. 11 | type PersonalizationClient struct { 12 | client *Client 13 | } 14 | 15 | func (c *PersonalizationClient) decode(resp []byte, err error) (*PersonalizationResponse, error) { 16 | if err != nil { 17 | return nil, err 18 | } 19 | var result PersonalizationResponse 20 | if err := json.Unmarshal(resp, &result); err != nil { 21 | return nil, fmt.Errorf("cannot unmarshal resp: %w", err) 22 | } 23 | return &result, nil 24 | } 25 | 26 | // Get obtains a PersonalizationResponse for the given resource and params. 27 | func (c *PersonalizationClient) Get(ctx context.Context, resource string, params map[string]any) (*PersonalizationResponse, error) { 28 | if resource == "" { 29 | return nil, errors.New("missing resource") 30 | } 31 | endpoint := c.client.makeEndpoint("%s/", resource) 32 | for k, v := range params { 33 | endpoint.addQueryParam(makeRequestOption(k, v)) 34 | } 35 | return c.decode(c.client.get(ctx, endpoint, nil, c.client.authenticator.personalizationAuth)) 36 | } 37 | 38 | // Post sends data to the given resource, adding the given params to the request. 39 | func (c *PersonalizationClient) Post(ctx context.Context, resource string, params, data map[string]any) (*PersonalizationResponse, error) { 40 | if resource == "" { 41 | return nil, errors.New("missing resource") 42 | } 43 | endpoint := c.client.makeEndpoint("%s/", resource) 44 | for k, v := range params { 45 | endpoint.addQueryParam(makeRequestOption(k, v)) 46 | } 47 | if data != nil { 48 | data = map[string]any{ 49 | "data": data, 50 | } 51 | } 52 | return c.decode(c.client.post(ctx, endpoint, data, c.client.authenticator.personalizationAuth)) 53 | } 54 | 55 | // Delete removes data from the given resource, adding the given params to the request. 56 | func (c *PersonalizationClient) Delete(ctx context.Context, resource string, params map[string]any) (*PersonalizationResponse, error) { 57 | if resource == "" { 58 | return nil, errors.New("missing resource") 59 | } 60 | endpoint := c.client.makeEndpoint("%s/", resource) 61 | for k, v := range params { 62 | endpoint.addQueryParam(makeRequestOption(k, v)) 63 | } 64 | return c.decode(c.client.delete(ctx, endpoint, nil, c.client.authenticator.personalizationAuth)) 65 | } 66 | -------------------------------------------------------------------------------- /personalization_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPersonalizationGet(t *testing.T) { 12 | ctx := context.Background() 13 | client, requester := newClient(t) 14 | p := client.Personalization() 15 | params := map[string]any{"answer": 42, "feed": "user:123"} 16 | _, err := p.Get(ctx, "", params) 17 | require.Error(t, err) 18 | _, err = p.Get(ctx, "some_resource", params) 19 | require.NoError(t, err) 20 | expectedURL := "https://personalization.stream-io-api.com/personalization/v1.0/some_resource/?answer=42&api_key=key&feed=user%3A123" 21 | testRequest(t, requester.req, http.MethodGet, expectedURL, "") 22 | } 23 | 24 | func TestPersonalizationPost(t *testing.T) { 25 | ctx := context.Background() 26 | client, requester := newClient(t) 27 | p := client.Personalization() 28 | params := map[string]any{"answer": 42, "feed": "user:123"} 29 | _, err := p.Post(ctx, "", params, nil) 30 | require.Error(t, err) 31 | data := map[string]any{"foo": "bar", "baz": 42} 32 | _, err = p.Post(ctx, "some_resource", params, data) 33 | require.NoError(t, err) 34 | expectedURL := "https://personalization.stream-io-api.com/personalization/v1.0/some_resource/?answer=42&api_key=key&feed=user%3A123" 35 | expectedBody := `{"data":{"baz":42,"foo":"bar"}}` 36 | testRequest(t, requester.req, http.MethodPost, expectedURL, expectedBody) 37 | } 38 | 39 | func TestPersonalizationDelete(t *testing.T) { 40 | ctx := context.Background() 41 | client, requester := newClient(t) 42 | p := client.Personalization() 43 | params := map[string]any{"answer": 42, "feed": "user:123"} 44 | _, err := p.Delete(ctx, "", params) 45 | require.Error(t, err) 46 | _, err = p.Delete(ctx, "some_resource", params) 47 | require.NoError(t, err) 48 | expectedURL := "https://personalization.stream-io-api.com/personalization/v1.0/some_resource/?answer=42&api_key=key&feed=user%3A123" 49 | testRequest(t, requester.req, http.MethodDelete, expectedURL, "") 50 | } 51 | -------------------------------------------------------------------------------- /reactions.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | // ReactionsClient is a specialized client used to interact with the Reactions endpoints. 11 | type ReactionsClient struct { 12 | client *Client 13 | } 14 | 15 | // Add adds a reaction. 16 | func (c *ReactionsClient) Add(ctx context.Context, r AddReactionRequestObject) (*ReactionResponse, error) { 17 | if r.ParentID != "" { 18 | return nil, errors.New("`Parent` not empty. For adding child reactions use `AddChild`") 19 | } 20 | return c.addReaction(ctx, r) 21 | } 22 | 23 | // AddChild adds a child reaction to the provided parent. 24 | func (c *ReactionsClient) AddChild(ctx context.Context, parentID string, r AddReactionRequestObject) (*ReactionResponse, error) { 25 | r.ParentID = parentID 26 | return c.addReaction(ctx, r) 27 | } 28 | 29 | func (c *ReactionsClient) addReaction(ctx context.Context, r AddReactionRequestObject) (*ReactionResponse, error) { 30 | endpoint := c.client.makeEndpoint("reaction/") 31 | return c.decode(c.client.post(ctx, endpoint, r, c.client.authenticator.reactionsAuth)) 32 | } 33 | 34 | func (c *ReactionsClient) decode(resp []byte, err error) (*ReactionResponse, error) { 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | var result ReactionResponse 40 | if err := json.Unmarshal(resp, &result); err != nil { 41 | return nil, err 42 | } 43 | return &result, nil 44 | } 45 | 46 | // Update updates the reaction's data and/or target feeds. 47 | func (c *ReactionsClient) Update(ctx context.Context, id string, data map[string]any, targetFeeds []string) (*ReactionResponse, error) { 48 | endpoint := c.client.makeEndpoint("reaction/%s/", id) 49 | 50 | reqData := map[string]any{ 51 | "data": data, 52 | "target_feeds": targetFeeds, 53 | } 54 | return c.decode(c.client.put(ctx, endpoint, reqData, c.client.authenticator.reactionsAuth)) 55 | } 56 | 57 | // Get retrieves a reaction having the given id. 58 | func (c *ReactionsClient) Get(ctx context.Context, id string) (*ReactionResponse, error) { 59 | endpoint := c.client.makeEndpoint("reaction/%s/", id) 60 | 61 | return c.decode(c.client.get(ctx, endpoint, nil, c.client.authenticator.reactionsAuth)) 62 | } 63 | 64 | // Delete deletes a reaction having the given id. 65 | // The reaction is permanently deleted and cannot be restored. 66 | // Returned reaction is empty. 67 | // Optional ReactionOption parameters can be provided, such as WithReactionUserID 68 | // to specify a user ID in the query string. 69 | func (c *ReactionsClient) Delete(ctx context.Context, id string, opts ...ReactionOption) (*ReactionResponse, error) { 70 | endpoint := c.client.makeEndpoint("reaction/%s/", id) 71 | 72 | for _, opt := range opts { 73 | endpoint.addQueryParam(opt) 74 | } 75 | 76 | return c.decode(c.client.delete(ctx, endpoint, nil, c.client.authenticator.reactionsAuth)) 77 | } 78 | 79 | // SoftDelete soft-deletes a reaction having the given id. It is possible to restore this reaction using ReactionsClient.Restore. 80 | // Optional ReactionOption parameters can be provided, such as WithReactionUserID 81 | // to specify a user ID in the query string. 82 | func (c *ReactionsClient) SoftDelete(ctx context.Context, id string, opts ...ReactionOption) error { 83 | endpoint := c.client.makeEndpoint("reaction/%s/", id) 84 | endpoint.addQueryParam(makeRequestOption("soft", true)) 85 | 86 | for _, opt := range opts { 87 | endpoint.addQueryParam(opt) 88 | } 89 | 90 | _, err := c.client.delete(ctx, endpoint, nil, c.client.authenticator.reactionsAuth) 91 | return err 92 | } 93 | 94 | // Restore restores a soft deleted reaction having the given id. 95 | // Optional ReactionOption parameters can be provided, such as WithReactionUserID 96 | // to specify a user ID in the query string. 97 | func (c *ReactionsClient) Restore(ctx context.Context, id string, opts ...ReactionOption) error { 98 | endpoint := c.client.makeEndpoint("reaction/%s/restore/", id) 99 | 100 | for _, opt := range opts { 101 | endpoint.addQueryParam(opt) 102 | } 103 | 104 | _, err := c.client.put(ctx, endpoint, nil, c.client.authenticator.reactionsAuth) 105 | return err 106 | } 107 | 108 | // Filter lists reactions based on the provided criteria and with the specified pagination. 109 | func (c *ReactionsClient) Filter(ctx context.Context, attr FilterReactionsAttribute, opts ...FilterReactionsOption) (*FilterReactionResponse, error) { 110 | endpointURI := fmt.Sprintf("reaction/%s/", attr()) 111 | 112 | endpoint := c.client.makeEndpoint(endpointURI) 113 | for _, opt := range opts { 114 | endpoint.addQueryParam(opt) 115 | } 116 | 117 | resp, err := c.client.get(ctx, endpoint, nil, c.client.authenticator.reactionsAuth) 118 | if err != nil { 119 | return nil, err 120 | } 121 | var result FilterReactionResponse 122 | if err := json.Unmarshal(resp, &result); err != nil { 123 | return nil, err 124 | } 125 | result.meta.attr = attr 126 | return &result, nil 127 | } 128 | 129 | // GetNextPageFilteredReactions returns the reactions at the "next" page of a previous *FilterReactionResponse response, if any. 130 | func (c *ReactionsClient) GetNextPageFilteredReactions(ctx context.Context, resp *FilterReactionResponse) (*FilterReactionResponse, error) { 131 | opts, err := resp.parseNext() 132 | if err != nil { 133 | return nil, err 134 | } 135 | return c.Filter(ctx, resp.meta.attr, opts...) 136 | } 137 | -------------------------------------------------------------------------------- /reactions_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | stream "github.com/GetStream/stream-go2/v8" 11 | ) 12 | 13 | func TestGetReaction(t *testing.T) { 14 | ctx := context.Background() 15 | client, requester := newClient(t) 16 | _, err := client.Reactions().Get(ctx, "id1") 17 | require.NoError(t, err) 18 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/reaction/id1/?api_key=key", "") 19 | } 20 | 21 | func TestDeleteReaction(t *testing.T) { 22 | ctx := context.Background() 23 | client, requester := newClient(t) 24 | _, err := client.Reactions().Delete(ctx, "id1") 25 | require.NoError(t, err) 26 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/reaction/id1/?api_key=key", "") 27 | } 28 | 29 | func TestAddReaction(t *testing.T) { 30 | ctx := context.Background() 31 | client, requester := newClient(t) 32 | 33 | testCases := []struct { 34 | input stream.AddReactionRequestObject 35 | expectedURL string 36 | expectedBody string 37 | }{ 38 | { 39 | input: stream.AddReactionRequestObject{ 40 | Kind: "like", 41 | ActivityID: "some-act-id", 42 | UserID: "user-id", 43 | Data: map[string]any{ 44 | "field": "value", 45 | }, 46 | }, 47 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/?api_key=key", 48 | expectedBody: `{"kind":"like","activity_id":"some-act-id","user_id":"user-id","data":{"field":"value"}}`, 49 | }, 50 | { 51 | input: stream.AddReactionRequestObject{ 52 | Kind: "like", 53 | ActivityID: "some-act-id", 54 | UserID: "user-id", 55 | TargetFeeds: []string{"user:bob"}, 56 | }, 57 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/?api_key=key", 58 | expectedBody: `{"kind":"like","activity_id":"some-act-id","user_id":"user-id","target_feeds":["user:bob"]}`, 59 | }, 60 | { 61 | input: stream.AddReactionRequestObject{ 62 | Kind: "like", 63 | ActivityID: "some-act-id", 64 | UserID: "user-id", 65 | Data: map[string]any{"some_extra": "on reaction"}, 66 | TargetFeeds: []string{"user:bob"}, 67 | TargetFeedsExtraData: map[string]any{"some_extra": "on activity"}, 68 | }, 69 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/?api_key=key", 70 | expectedBody: `{ 71 | "kind":"like","activity_id":"some-act-id","user_id":"user-id", 72 | "data":{"some_extra":"on reaction"}, 73 | "target_feeds":["user:bob"], 74 | "target_feeds_extra_data":{"some_extra":"on activity"} 75 | }`, 76 | }, 77 | } 78 | for _, tc := range testCases { 79 | _, err := client.Reactions().Add(ctx, tc.input) 80 | require.NoError(t, err) 81 | testRequest(t, requester.req, http.MethodPost, tc.expectedURL, tc.expectedBody) 82 | } 83 | } 84 | 85 | func TestAddChildReaction(t *testing.T) { 86 | ctx := context.Background() 87 | client, requester := newClient(t) 88 | 89 | reaction := stream.AddReactionRequestObject{ 90 | Kind: "like", 91 | ActivityID: "some-act-id", 92 | UserID: "user-id", 93 | Data: map[string]any{ 94 | "field": "value", 95 | }, 96 | TargetFeeds: []string{"stalker:timeline"}, 97 | TargetFeedsExtraData: map[string]any{ 98 | "activity_field": "activity_value", 99 | }, 100 | } 101 | expectedURL := "https://api.stream-io-api.com/api/v1.0/reaction/?api_key=key" 102 | expectedBody := `{ 103 | "kind":"like","activity_id":"some-act-id","user_id":"user-id", 104 | "data":{"field":"value"},"parent":"pid", 105 | "target_feeds": ["stalker:timeline"],"target_feeds_extra_data":{"activity_field":"activity_value"} 106 | }` 107 | 108 | _, err := client.Reactions().AddChild(ctx, "pid", reaction) 109 | require.NoError(t, err) 110 | testRequest(t, requester.req, http.MethodPost, expectedURL, expectedBody) 111 | } 112 | 113 | func TestUpdateReaction(t *testing.T) { 114 | ctx := context.Background() 115 | client, requester := newClient(t) 116 | 117 | testCases := []struct { 118 | id string 119 | data map[string]any 120 | targetFeeds []string 121 | expectedURL string 122 | expectedBody string 123 | }{ 124 | { 125 | id: "r-id", 126 | data: map[string]any{ 127 | "field": "value", 128 | }, 129 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/r-id/?api_key=key", 130 | expectedBody: `{"data":{"field":"value"}}`, 131 | }, 132 | { 133 | id: "r-id2", 134 | targetFeeds: []string{"user:bob"}, 135 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/r-id2/?api_key=key", 136 | expectedBody: `{"target_feeds":["user:bob"]}`, 137 | }, 138 | } 139 | for _, tc := range testCases { 140 | _, err := client.Reactions().Update(ctx, tc.id, tc.data, tc.targetFeeds) 141 | require.NoError(t, err) 142 | testRequest(t, requester.req, http.MethodPut, tc.expectedURL, tc.expectedBody) 143 | } 144 | } 145 | 146 | func TestFilterReactions(t *testing.T) { 147 | ctx := context.Background() 148 | client, requester := newClient(t) 149 | testCases := []struct { 150 | attr stream.FilterReactionsAttribute 151 | opts []stream.FilterReactionsOption 152 | expectedURL string 153 | }{ 154 | { 155 | attr: stream.ByActivityID("aid"), 156 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/activity_id/aid/?api_key=key", 157 | }, 158 | { 159 | attr: stream.ByReactionID("rid"), 160 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/reaction_id/rid/?api_key=key", 161 | }, 162 | { 163 | attr: stream.ByUserID("uid"), 164 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/?api_key=key", 165 | }, 166 | { 167 | attr: stream.ByUserID("uid").ByKind("upvote"), 168 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/upvote/?api_key=key", 169 | }, 170 | { 171 | attr: stream.ByUserID("uid").ByKind("upvote"), 172 | opts: []stream.FilterReactionsOption{stream.WithLimit(100)}, 173 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/upvote/?api_key=key&limit=100", 174 | }, 175 | { 176 | attr: stream.ByUserID("uid").ByKind("upvote"), 177 | opts: []stream.FilterReactionsOption{stream.WithLimit(100), stream.WithActivityData(), stream.WithIDGTE("uid1")}, 178 | expectedURL: "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/upvote/?api_key=key&id_gte=uid1&limit=100&with_activity_data=true", 179 | }, 180 | } 181 | 182 | for _, tc := range testCases { 183 | _, err := client.Reactions().Filter(ctx, tc.attr, tc.opts...) 184 | require.NoError(t, err) 185 | testRequest(t, requester.req, http.MethodGet, tc.expectedURL, "") 186 | } 187 | } 188 | 189 | func TestSoftDeleteReaction(t *testing.T) { 190 | ctx := context.Background() 191 | client, requester := newClient(t) 192 | 193 | err := client.Reactions().SoftDelete(ctx, "rid") 194 | require.NoError(t, err) 195 | 196 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/reaction/rid/?api_key=key&soft=true", "") 197 | } 198 | 199 | func TestRestoreReaction(t *testing.T) { 200 | ctx := context.Background() 201 | client, requester := newClient(t) 202 | 203 | err := client.Reactions().Restore(ctx, "rid") 204 | require.NoError(t, err) 205 | 206 | testRequest(t, requester.req, http.MethodPut, "https://api.stream-io-api.com/api/v1.0/reaction/rid/restore/?api_key=key", "") 207 | } 208 | 209 | func TestGetNextPageReactions(t *testing.T) { 210 | ctx := context.Background() 211 | client, requester := newClient(t) 212 | 213 | requester.resp = `{"next":"/api/v1.0/reaction/user_id/uid/upvote/?api_key=key&id_gt=uid1&limit=100&with_activity_data=true"}` 214 | resp, err := client.Reactions().Filter(ctx, stream.ByUserID("uid").ByKind("like"), stream.WithLimit(10), stream.WithActivityData(), stream.WithIDGT("id1")) 215 | require.NoError(t, err) 216 | 217 | _, err = client.Reactions().GetNextPageFilteredReactions(ctx, resp) 218 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/like/?api_key=key&id_gt=uid1&limit=100&with_activity_data=true", "") 219 | require.NoError(t, err) 220 | 221 | requester.resp = `{"next":"/api/v1.0/reaction/user_id/uid/upvote/?api_key=key&id_gt=uid1&limit=100&with_own_children=true"}` 222 | resp, err = client.Reactions().Filter( 223 | ctx, 224 | stream.ByUserID("uid").ByKind("like"), 225 | stream.WithLimit(10), 226 | stream.WithOwnChildren(), 227 | stream.WithIDGT("id1"), 228 | ) 229 | require.NoError(t, err) 230 | 231 | requester.resp = `{"next":"/api/v1.0/reaction/user_id/uid/upvote/?api_key=key&id_gt=uid1&limit=100&with_own_children=true&with_own_children_kinds=comment,like&user_id=something&children_user_id=child_user_id"}` 232 | _, err = client.Reactions().Filter( 233 | ctx, 234 | stream.ByUserID("uid").ByKind("like"), 235 | stream.WithLimit(10), 236 | stream.WithIDGT("id1"), 237 | stream.WithOwnChildren(), 238 | stream.FilterReactionsOption(stream.WithEnrichOwnChildrenKindsFilter("comment", "like")), 239 | ) 240 | require.NoError(t, err) 241 | 242 | _, err = client.Reactions().GetNextPageFilteredReactions(ctx, resp) 243 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/like/?api_key=key&id_gt=uid1&limit=100&with_own_children=true", "") 244 | require.NoError(t, err) 245 | 246 | requester.resp = `{"next":"/api/v1.0/reaction/user_id/uid/upvote/?api_key=key&id_gt=uid1&limit=100&with_activity_data=false"}` 247 | resp, err = client.Reactions().Filter(ctx, stream.ByUserID("uid").ByKind("like"), stream.WithLimit(10), stream.WithActivityData(), stream.WithIDGT("id1")) 248 | require.NoError(t, err) 249 | 250 | _, err = client.Reactions().GetNextPageFilteredReactions(ctx, resp) 251 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/reaction/user_id/uid/like/?api_key=key&id_gt=uid1&limit=100", "") 252 | require.NoError(t, err) 253 | 254 | requester.resp = `{"next":"123"}` 255 | resp, err = client.Reactions().Filter(ctx, stream.ByActivityID("aid")) 256 | require.NoError(t, err) 257 | _, err = client.Reactions().GetNextPageFilteredReactions(ctx, resp) 258 | require.Error(t, err) 259 | 260 | requester.resp = `{"next":"?q=a%"}` 261 | resp, err = client.Reactions().Filter(ctx, stream.ByActivityID("aid")) 262 | require.NoError(t, err) 263 | _, err = client.Reactions().GetNextPageFilteredReactions(ctx, resp) 264 | require.Error(t, err) 265 | } 266 | 267 | func TestDeleteReactionWithUserID(t *testing.T) { 268 | ctx := context.Background() 269 | client, requester := newClient(t) 270 | _, err := client.Reactions().Delete(ctx, "id1", stream.WithReactionUserID("user1")) 271 | require.NoError(t, err) 272 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/reaction/id1/?api_key=key&user_id=user1", "") 273 | } 274 | 275 | func TestSoftDeleteReactionWithUserID(t *testing.T) { 276 | ctx := context.Background() 277 | client, requester := newClient(t) 278 | 279 | err := client.Reactions().SoftDelete(ctx, "rid", stream.WithReactionUserID("user1")) 280 | require.NoError(t, err) 281 | 282 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/reaction/rid/?api_key=key&soft=true&user_id=user1", "") 283 | } 284 | 285 | func TestRestoreReactionWithUserID(t *testing.T) { 286 | ctx := context.Background() 287 | client, requester := newClient(t) 288 | 289 | err := client.Reactions().Restore(ctx, "rid", stream.WithReactionUserID("user1")) 290 | require.NoError(t, err) 291 | 292 | testRequest(t, requester.req, http.MethodPut, "https://api.stream-io-api.com/api/v1.0/reaction/rid/restore/?api_key=key&user_id=user1", "") 293 | } 294 | -------------------------------------------------------------------------------- /run-lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | [[ -n ${DEBUG:-} ]] && set -x 6 | 7 | gopath="$(go env GOPATH)" 8 | 9 | if ! [[ -x "$gopath/bin/golangci-lint" ]]; then 10 | echo >&2 'Installing golangci-lint' 11 | curl --silent --fail --location \ 12 | https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$gopath/bin" v1.46.0 13 | fi 14 | 15 | # configured by .golangci.yml 16 | "$gopath/bin/golangci-lint" run --out-format line-number 17 | -------------------------------------------------------------------------------- /scripts/get_changelog_diff.js: -------------------------------------------------------------------------------- 1 | /* 2 | Here we're trying to parse the latest changes from CHANGELOG.md file. 3 | The changelog looks like this: 4 | 5 | ## 0.0.3 6 | - Something #3 7 | ## 0.0.2 8 | - Something #2 9 | ## 0.0.1 10 | - Something #1 11 | 12 | In this case we're trying to extract "- Something #3" since that's the latest change. 13 | */ 14 | module.exports = () => { 15 | const fs = require('fs') 16 | 17 | changelog = fs.readFileSync('CHANGELOG.md', 'utf8') 18 | releases = changelog.match(/## [?[0-9](.+)/g) 19 | 20 | current_release = changelog.indexOf(releases[0]) 21 | previous_release = changelog.indexOf(releases[1]) 22 | 23 | latest_changes = changelog.substr(current_release, previous_release - current_release) 24 | 25 | return latest_changes 26 | } -------------------------------------------------------------------------------- /stream.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "github.com/fatih/structs" 5 | ) 6 | 7 | const ( 8 | // TimeLayout is the default time parse layout for Stream API JSON time fields 9 | TimeLayout = "2006-01-02T15:04:05.999999" 10 | // ReactionTimeLayout is the time parse layout for Stream Reaction API JSON time fields 11 | ReactionTimeLayout = "2006-01-02T15:04:05.999999Z07:00" 12 | ) 13 | 14 | var timeLayouts = []string{ 15 | TimeLayout, 16 | ReactionTimeLayout, 17 | "2006-01-02 15:04:05.999999-07:00", 18 | } 19 | 20 | func init() { 21 | structs.DefaultTagName = "json" 22 | } 23 | -------------------------------------------------------------------------------- /types_internal_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestReadResponse_parseNext(t *testing.T) { 12 | testCases := []struct { 13 | next string 14 | shouldError bool 15 | err error 16 | expected []GetActivitiesOption 17 | }{ 18 | { 19 | next: "", 20 | shouldError: true, 21 | err: ErrMissingNextPage, 22 | }, 23 | { 24 | next: "/test", 25 | shouldError: true, 26 | err: ErrInvalidNextPage, 27 | }, 28 | { 29 | next: "/test?k=%", 30 | shouldError: true, 31 | err: ErrInvalidNextPage, 32 | }, 33 | { 34 | next: "/test?limit=a", 35 | shouldError: true, 36 | err: fmt.Errorf(`strconv.Atoi: parsing "a": invalid syntax`), 37 | }, 38 | { 39 | next: "/test?offset=a", 40 | shouldError: true, 41 | err: fmt.Errorf(`strconv.Atoi: parsing "a": invalid syntax`), 42 | }, 43 | { 44 | next: "/test?limit=1&offset=2", 45 | shouldError: false, 46 | expected: []GetActivitiesOption{ 47 | WithActivitiesLimit(1), 48 | WithActivitiesOffset(2), 49 | }, 50 | }, 51 | { 52 | next: "/test?limit=1&offset=2&id_lt=foo", 53 | shouldError: false, 54 | expected: []GetActivitiesOption{ 55 | WithActivitiesLimit(1), 56 | WithActivitiesOffset(2), 57 | WithActivitiesIDLT("foo"), 58 | }, 59 | }, 60 | { 61 | next: "/test?limit=1&offset=2&id_lt=foo&ranking=bar", 62 | shouldError: false, 63 | expected: []GetActivitiesOption{ 64 | WithActivitiesLimit(1), 65 | WithActivitiesOffset(2), 66 | WithActivitiesIDLT("foo"), 67 | WithActivitiesRanking("bar"), 68 | }, 69 | }, 70 | { 71 | next: "/test?withOwnChildren=false&withRecentReactions=true&recentReactionsLimit=12&reactionKindsFilter=like,comment,upvote", 72 | shouldError: false, 73 | expected: []GetActivitiesOption{ 74 | WithEnrichRecentReactions(), 75 | WithEnrichRecentReactionsLimit(12), 76 | WithEnrichReactionKindsFilter("like", "comment", "upvote"), 77 | }, 78 | }, 79 | { 80 | next: "/test?withOwnChildren=true&withOwnChildrenKinds=like&withFirstReactions=true&reaction_limit=12&reactionKindsFilter=like,comment,upvote", 81 | shouldError: false, 82 | expected: []GetActivitiesOption{ 83 | WithEnrichFirstReactions(), 84 | WithEnrichOwnChildren(), 85 | WithEnrichReactionsLimit(12), 86 | WithEnrichReactionKindsFilter("like", "comment", "upvote"), 87 | WithEnrichOwnChildrenKindsFilter("like"), 88 | }, 89 | }, 90 | } 91 | 92 | for _, tc := range testCases { 93 | r := readResponse{Next: tc.next} 94 | opts, err := r.parseNext() 95 | if tc.shouldError { 96 | require.Error(t, err) 97 | assert.Equal(t, tc.err.Error(), err.Error()) 98 | } else { 99 | require.NoError(t, err) 100 | assert.Equal(t, tc.expected, opts) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /types_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | stream "github.com/GetStream/stream-go2/v8" 12 | ) 13 | 14 | func TestDurationMarshalUnmarshalJSON(t *testing.T) { 15 | dur := stream.Duration{Duration: 33 * time.Second} 16 | data := []byte(`"33s"`) 17 | marshaled, err := json.Marshal(dur) 18 | assert.NoError(t, err) 19 | assert.Equal(t, data, marshaled) 20 | var out stream.Duration 21 | err = json.Unmarshal(marshaled, &out) 22 | assert.NoError(t, err) 23 | assert.Equal(t, dur, out) 24 | } 25 | 26 | func TestTimeMarshalUnmarshalJSON(t *testing.T) { 27 | tt, _ := time.Parse("2006-Jan-02", "2013-Feb-03") 28 | st := stream.Time{Time: tt} 29 | data := []byte(`"2013-02-03T00:00:00"`) 30 | marshaled, err := json.Marshal(st) 31 | require.NoError(t, err) 32 | require.Equal(t, data, marshaled) 33 | var out stream.Time 34 | err = json.Unmarshal(marshaled, &out) 35 | assert.NoError(t, err) 36 | assert.Equal(t, st, out) 37 | zone, _ := out.Time.Zone() 38 | assert.Equal(t, "UTC", zone) 39 | 40 | // test in local timezone 41 | now := stream.Time{Time: time.Now()} 42 | b, err := json.Marshal(now) 43 | require.NoError(t, err) 44 | err = json.Unmarshal(b, &out) 45 | require.NoError(t, err) 46 | assert.Equal(t, now.Unix(), out.Unix()) 47 | zone, _ = out.Time.Zone() 48 | assert.Equal(t, "UTC", zone) 49 | 50 | // test in America/Los_Angeles timezone 51 | la, err := time.LoadLocation("America/Los_Angeles") 52 | require.NoError(t, err) 53 | now = stream.Time{Time: time.Now().In(la)} 54 | b, err = json.Marshal(now) 55 | require.NoError(t, err) 56 | err = json.Unmarshal(b, &out) 57 | require.NoError(t, err) 58 | assert.Equal(t, now.Unix(), out.Unix()) 59 | zone, _ = out.Time.Zone() 60 | assert.Equal(t, "UTC", zone) 61 | 62 | // test in UTC timezone 63 | now = stream.Time{Time: time.Now().UTC()} 64 | b, err = json.Marshal(now) 65 | require.NoError(t, err) 66 | err = json.Unmarshal(b, &out) 67 | require.NoError(t, err) 68 | assert.Equal(t, now.Unix(), out.Unix()) 69 | zone, _ = out.Time.Zone() 70 | assert.Equal(t, "UTC", zone) 71 | 72 | // test with unix timestamp 73 | now = stream.Time{Time: time.Unix(1234, 0)} 74 | b, err = json.Marshal(now) 75 | require.NoError(t, err) 76 | err = json.Unmarshal(b, &out) 77 | require.NoError(t, err) 78 | assert.Equal(t, now.Unix(), out.Unix()) 79 | assert.Equal(t, int64(1234), out.Unix()) 80 | zone, _ = out.Time.Zone() 81 | assert.Equal(t, "UTC", zone) 82 | 83 | // test with time.Date 84 | d := time.Date(2023, time.May, 10, 15, 25, 52, 0, la) 85 | now = stream.Time{Time: d} 86 | b, err = json.Marshal(now) 87 | require.NoError(t, err) 88 | err = json.Unmarshal(b, &out) 89 | require.NoError(t, err) 90 | assert.Equal(t, d.Unix(), out.Unix()) 91 | zone, _ = out.Time.Zone() 92 | assert.Equal(t, "UTC", zone) 93 | } 94 | 95 | func TestEnrichedActivityMarshal(t *testing.T) { 96 | e := stream.EnrichedActivity{ 97 | Actor: stream.Data{ 98 | ID: "my_id", 99 | Extra: map[string]any{"a": 1, "b": "c"}, 100 | }, 101 | ReactionCounts: map[string]int{ 102 | "comment": 1, 103 | }, 104 | Score: 100.0, 105 | } 106 | 107 | b, err := json.Marshal(e) 108 | require.NoError(t, err) 109 | require.JSONEq(t, `{"actor": {"id":"my_id","data":{"a":1,"b":"c"}},"reaction_counts":{"comment":1},"score":100.0}`, string(b)) 110 | } 111 | -------------------------------------------------------------------------------- /url.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | const domain = "stream-io-api.com" 9 | 10 | type urlBuilder interface { 11 | url() string 12 | } 13 | 14 | // handy rewrites for regions 15 | var regionOverrides = map[string]string{ 16 | "us-east": "us-east-api", 17 | "eu-west": "eu-west-api", 18 | "singapore": "singapore-api", 19 | } 20 | 21 | var personalizationOverrides = map[string]string{ 22 | "eu-west": "dublin", 23 | "dublin": "dublin", 24 | } 25 | 26 | type regionalURLBuilder struct { 27 | region string 28 | version string 29 | } 30 | 31 | func newRegionalURLBuilder(region, version string) regionalURLBuilder { 32 | return regionalURLBuilder{ 33 | region: region, 34 | version: version, 35 | } 36 | } 37 | 38 | func (u regionalURLBuilder) makeHost(subdomain string) string { 39 | if envHost := os.Getenv("STREAM_URL"); envHost != "" { 40 | return envHost 41 | } 42 | return fmt.Sprintf("https://%s.%s", u.makeRegion(subdomain), domain) 43 | } 44 | 45 | func (u regionalURLBuilder) makeVersion() string { 46 | if u.version != "" { 47 | return u.version 48 | } 49 | return "1.0" 50 | } 51 | 52 | func (u regionalURLBuilder) makeRegion(subdomain string) string { 53 | if u.region != "" { 54 | if override, ok := regionOverrides[u.region]; ok { 55 | return override 56 | } 57 | return u.region 58 | } 59 | return subdomain 60 | } 61 | 62 | type apiURLBuilder struct { 63 | addr string 64 | regionalURLBuilder 65 | } 66 | 67 | func newAPIURLBuilder(addr, region, version string) apiURLBuilder { 68 | return apiURLBuilder{addr, newRegionalURLBuilder(region, version)} 69 | } 70 | 71 | func (u apiURLBuilder) url() string { 72 | if u.addr != "" { 73 | return fmt.Sprintf("%s/api/v%s/", u.addr, u.makeVersion()) 74 | } 75 | return fmt.Sprintf("%s/api/v%s/", u.makeHost("api"), u.makeVersion()) 76 | } 77 | 78 | type personalizationURLBuilder struct { 79 | region string 80 | } 81 | 82 | func newPersonalizationURLBuilder(region string) personalizationURLBuilder { 83 | return personalizationURLBuilder{ 84 | region: region, 85 | } 86 | } 87 | 88 | func (b personalizationURLBuilder) url() string { 89 | if envHost := os.Getenv("STREAM_URL"); envHost != "" { 90 | return envHost 91 | } 92 | defaultPath := fmt.Sprintf("personalization.%s/personalization/v1.0/", domain) 93 | if override, ok := personalizationOverrides[b.region]; ok { 94 | return fmt.Sprintf("https://%s-%s", override, defaultPath) 95 | } 96 | 97 | return fmt.Sprintf("https://%s", defaultPath) 98 | } 99 | 100 | type analyticsURLBuilder struct { 101 | regionalURLBuilder 102 | } 103 | 104 | func newAnalyticsURLBuilder(region, version string) analyticsURLBuilder { 105 | return analyticsURLBuilder{newRegionalURLBuilder(region, version)} 106 | } 107 | 108 | func (u analyticsURLBuilder) url() string { 109 | return fmt.Sprintf("%s/analytics/v%s/", u.makeHost("analytics"), u.makeVersion()) 110 | } 111 | -------------------------------------------------------------------------------- /url_internal_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_URLString(t *testing.T) { 11 | testCases := []struct { 12 | urlBuilder apiURLBuilder 13 | expected string 14 | }{ 15 | { 16 | urlBuilder: apiURLBuilder{}, 17 | expected: fmt.Sprintf("https://api.%s/api/v1.0/", domain), 18 | }, 19 | { 20 | urlBuilder: newAPIURLBuilder("", "us-east", "2.0"), 21 | expected: fmt.Sprintf("https://us-east-api.%s/api/v2.0/", domain), 22 | }, 23 | { 24 | urlBuilder: newAPIURLBuilder("", "eu-west", "2.0"), 25 | expected: fmt.Sprintf("https://eu-west-api.%s/api/v2.0/", domain), 26 | }, 27 | { 28 | urlBuilder: newAPIURLBuilder("", "singapore", "2.0"), 29 | expected: fmt.Sprintf("https://singapore-api.%s/api/v2.0/", domain), 30 | }, 31 | { 32 | urlBuilder: newAPIURLBuilder("http://localhost:8000", "singapore", "1.0"), 33 | expected: "http://localhost:8000/api/v1.0/", 34 | }, 35 | { 36 | urlBuilder: newAPIURLBuilder("http://localhost:8000", "", "1.0"), 37 | expected: "http://localhost:8000/api/v1.0/", 38 | }, 39 | { 40 | urlBuilder: newAPIURLBuilder("http://localhost:8000", "", "2.0"), 41 | expected: "http://localhost:8000/api/v2.0/", 42 | }, 43 | } 44 | 45 | for _, tc := range testCases { 46 | assert.Equal(t, tc.expected, tc.urlBuilder.url()) 47 | } 48 | } 49 | 50 | func Test_PersonalizationURLString(t *testing.T) { 51 | testCases := []struct { 52 | urlBuilder personalizationURLBuilder 53 | expected string 54 | }{ 55 | { 56 | urlBuilder: personalizationURLBuilder{}, 57 | expected: "https://personalization.stream-io-api.com/personalization/v1.0/", 58 | }, 59 | { 60 | urlBuilder: personalizationURLBuilder{"us-east"}, 61 | expected: "https://personalization.stream-io-api.com/personalization/v1.0/", 62 | }, 63 | { 64 | urlBuilder: personalizationURLBuilder{"eu-west"}, 65 | expected: "https://dublin-personalization.stream-io-api.com/personalization/v1.0/", 66 | }, 67 | } 68 | 69 | for _, tc := range testCases { 70 | assert.Equal(t, tc.expected, tc.urlBuilder.url()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /users.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | // UsersClient is a specialized client used to interact with the Users endpoints. 10 | type UsersClient struct { 11 | client *Client 12 | } 13 | 14 | func (c *UsersClient) decode(resp []byte, err error) (*UserResponse, error) { 15 | if err != nil { 16 | return nil, err 17 | } 18 | var result UserResponse 19 | if err := json.Unmarshal(resp, &result); err != nil { 20 | return nil, err 21 | } 22 | return &result, nil 23 | } 24 | 25 | // Add adds a new user with the specified id and optional extra data. 26 | func (c *UsersClient) Add(ctx context.Context, user User, getOrCreate bool) (*UserResponse, error) { 27 | endpoint := c.client.makeEndpoint("user/") 28 | endpoint.addQueryParam(makeRequestOption("get_or_create", getOrCreate)) 29 | 30 | return c.decode(c.client.post(ctx, endpoint, user, c.client.authenticator.usersAuth)) 31 | } 32 | 33 | // Update updates the user's data. 34 | func (c *UsersClient) Update(ctx context.Context, id string, data map[string]any) (*UserResponse, error) { 35 | endpoint := c.client.makeEndpoint("user/%s/", id) 36 | 37 | reqData := map[string]any{ 38 | "data": data, 39 | } 40 | return c.decode(c.client.put(ctx, endpoint, reqData, c.client.authenticator.usersAuth)) 41 | } 42 | 43 | // Get retrieves a user having the given id. 44 | func (c *UsersClient) Get(ctx context.Context, id string) (*UserResponse, error) { 45 | endpoint := c.client.makeEndpoint("user/%s/", id) 46 | 47 | return c.decode(c.client.get(ctx, endpoint, nil, c.client.authenticator.usersAuth)) 48 | } 49 | 50 | // Delete deletes a user having the given id. 51 | func (c *UsersClient) Delete(ctx context.Context, id string) (*BaseResponse, error) { 52 | endpoint := c.client.makeEndpoint("user/%s/", id) 53 | 54 | return decode(c.client.delete(ctx, endpoint, nil, c.client.authenticator.usersAuth)) 55 | } 56 | 57 | // CreateReference returns a new reference string in the form SU:. 58 | func (c *UsersClient) CreateReference(id string) string { 59 | return fmt.Sprintf("SU:%s", id) 60 | } 61 | 62 | // CreateUserReference is a convenience helper not to require a client. 63 | var CreateUserReference = (&UsersClient{}).CreateReference 64 | -------------------------------------------------------------------------------- /users_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | stream "github.com/GetStream/stream-go2/v8" 12 | ) 13 | 14 | func TestUserRefHelpers(t *testing.T) { 15 | client, _ := newClient(t) 16 | ref := client.Users().CreateReference("bar") 17 | assert.Equal(t, "SU:bar", ref) 18 | } 19 | 20 | func TestGetUser(t *testing.T) { 21 | ctx := context.Background() 22 | client, requester := newClient(t) 23 | 24 | _, err := client.Users().Get(ctx, "id1") 25 | require.NoError(t, err) 26 | testRequest(t, requester.req, http.MethodGet, "https://api.stream-io-api.com/api/v1.0/user/id1/?api_key=key", "") 27 | } 28 | 29 | func TestDeleteUser(t *testing.T) { 30 | ctx := context.Background() 31 | client, requester := newClient(t) 32 | 33 | _, err := client.Users().Delete(ctx, "id1") 34 | require.NoError(t, err) 35 | testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/user/id1/?api_key=key", "") 36 | } 37 | 38 | func TestAddUser(t *testing.T) { 39 | ctx := context.Background() 40 | client, requester := newClient(t) 41 | testCases := []struct { 42 | object stream.User 43 | getOrCreate bool 44 | expectedURL string 45 | expectedBody string 46 | }{ 47 | { 48 | object: stream.User{ 49 | ID: "user-test", 50 | Data: map[string]any{ 51 | "is_admin": true, 52 | "name": "Johnny", 53 | }, 54 | }, 55 | expectedURL: "https://api.stream-io-api.com/api/v1.0/user/?api_key=key&get_or_create=false", 56 | expectedBody: `{"id":"user-test","data":{"is_admin":true,"name":"Johnny"}}`, 57 | }, 58 | { 59 | object: stream.User{ 60 | ID: "user-test", 61 | Data: map[string]any{ 62 | "is_admin": true, 63 | "name": "Jane", 64 | }, 65 | }, 66 | getOrCreate: true, 67 | expectedURL: "https://api.stream-io-api.com/api/v1.0/user/?api_key=key&get_or_create=true", 68 | expectedBody: `{"id":"user-test","data":{"is_admin":true,"name":"Jane"}}`, 69 | }, 70 | } 71 | 72 | for _, tc := range testCases { 73 | _, err := client.Users().Add(ctx, tc.object, tc.getOrCreate) 74 | require.NoError(t, err) 75 | testRequest(t, requester.req, http.MethodPost, tc.expectedURL, tc.expectedBody) 76 | } 77 | } 78 | 79 | func TestUpdateUser(t *testing.T) { 80 | ctx := context.Background() 81 | client, requester := newClient(t) 82 | 83 | data := map[string]any{ 84 | "name": "Jane", 85 | } 86 | _, err := client.Users().Update(ctx, "123", data) 87 | require.NoError(t, err) 88 | expectedBody := `{"data":{"name":"Jane"}}` 89 | testRequest(t, requester.req, http.MethodPut, "https://api.stream-io-api.com/api/v1.0/user/123/?api_key=key", expectedBody) 90 | } 91 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "reflect" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/mitchellh/mapstructure" 14 | ) 15 | 16 | func decodeJSONHook(f, typ reflect.Type, data any) (any, error) { 17 | switch typ { 18 | case reflect.TypeOf(Time{}): 19 | return timeFromString(data.(string)) 20 | case reflect.TypeOf(Duration{}): 21 | switch v := data.(type) { 22 | case string: 23 | return durationFromString(v) 24 | case float64: 25 | return durationFromString(fmt.Sprintf("%fs", v)) 26 | default: 27 | return nil, errors.New("invalid duration") 28 | } 29 | case reflect.TypeOf(Data{}): 30 | switch v := data.(type) { 31 | case string: 32 | return Data{ 33 | ID: v, 34 | }, nil 35 | case map[string]any: 36 | a := Data{} 37 | if err := a.decode(v); err != nil { 38 | return nil, err 39 | } 40 | return a, nil 41 | default: 42 | return nil, errors.New("invalid data") 43 | } 44 | } 45 | return data, nil 46 | } 47 | 48 | func decodeData(data map[string]any, target any) (*mapstructure.Metadata, error) { 49 | cfg := &mapstructure.DecoderConfig{ 50 | DecodeHook: decodeJSONHook, 51 | Result: target, 52 | Metadata: &mapstructure.Metadata{}, 53 | TagName: "json", 54 | } 55 | dec, err := mapstructure.NewDecoder(cfg) 56 | if err != nil { 57 | return nil, err 58 | } 59 | if err := dec.Decode(data); err != nil { 60 | return nil, err 61 | } 62 | return cfg.Metadata, nil 63 | } 64 | 65 | func parseIntValue(values url.Values, key string) (val int, exits bool, err error) { 66 | v := values.Get(key) 67 | if v == "" { 68 | return 0, false, nil 69 | } 70 | i, err := strconv.Atoi(v) 71 | if err != nil { 72 | return 0, false, err 73 | } 74 | return i, true, nil 75 | } 76 | 77 | func parseBool(value string) bool { 78 | v := strings.ToLower(value) 79 | return v != "" && v != "false" && v != "f" && v != "0" 80 | } 81 | 82 | func decode(resp []byte, err error) (*BaseResponse, error) { 83 | if err != nil { 84 | return nil, err 85 | } 86 | var result BaseResponse 87 | if err := json.Unmarshal(resp, &result); err != nil { 88 | return nil, err 89 | } 90 | return &result, nil 91 | } 92 | 93 | // set environment values and return a func to reset old values 94 | func resetEnv(values map[string]string) (func(), error) { 95 | old := map[string]string{} 96 | 97 | for k, v := range values { 98 | old[k] = os.Getenv(k) 99 | if err := os.Setenv(k, v); err != nil { 100 | return nil, err 101 | } 102 | } 103 | 104 | return func() { 105 | for k, v := range old { 106 | _ = os.Setenv(k, v) 107 | } 108 | }, nil 109 | } 110 | -------------------------------------------------------------------------------- /utils_internal_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func Test_decodeJSONHook(t *testing.T) { 14 | now, _ := time.Parse(TimeLayout, "2006-01-02T15:04:05.999999") 15 | testCases := []struct { 16 | f reflect.Type 17 | typ reflect.Type 18 | data any 19 | expected any 20 | shouldError bool 21 | }{ 22 | { 23 | f: reflect.TypeOf(123), 24 | data: 123, 25 | expected: 123, 26 | }, 27 | { 28 | f: reflect.TypeOf(""), 29 | typ: reflect.TypeOf(Duration{}), 30 | data: "1m2s", 31 | expected: Duration{time.Minute + time.Second*2}, 32 | }, 33 | { 34 | f: reflect.TypeOf(""), 35 | typ: reflect.TypeOf(Duration{}), 36 | data: "test", 37 | shouldError: true, 38 | }, 39 | { 40 | f: reflect.TypeOf(""), 41 | typ: reflect.TypeOf(Time{}), 42 | data: now.Format(TimeLayout), 43 | expected: Time{now}, 44 | }, 45 | { 46 | f: reflect.TypeOf(""), 47 | typ: reflect.TypeOf(Time{}), 48 | data: "test", 49 | shouldError: true, 50 | }, 51 | { 52 | f: reflect.TypeOf(float64(0)), 53 | typ: reflect.TypeOf(Duration{}), 54 | data: float64(42), 55 | shouldError: false, 56 | expected: Duration{time.Second * 42}, 57 | }, 58 | { 59 | f: reflect.TypeOf(float64(0)), 60 | typ: reflect.TypeOf(Duration{}), 61 | data: struct{}{}, 62 | shouldError: true, 63 | }, 64 | { 65 | f: reflect.TypeOf(string("")), 66 | typ: reflect.TypeOf(Data{}), 67 | data: "test", 68 | shouldError: false, 69 | expected: Data{ID: "test"}, 70 | }, 71 | { 72 | f: reflect.TypeOf(map[string]any{}), 73 | typ: reflect.TypeOf(Data{}), 74 | data: map[string]any{ 75 | "id": "test", 76 | "extra": "data", 77 | }, 78 | shouldError: false, 79 | expected: Data{ID: "test", Extra: map[string]any{"extra": "data"}}, 80 | }, 81 | { 82 | f: reflect.TypeOf(float64(0)), 83 | typ: reflect.TypeOf(Data{}), 84 | data: struct{}{}, 85 | shouldError: true, 86 | }, 87 | } 88 | for _, tc := range testCases { 89 | out, err := decodeJSONHook(tc.f, tc.typ, tc.data) 90 | if tc.shouldError { 91 | require.Error(t, err) 92 | } else { 93 | require.NoError(t, err) 94 | assert.Equal(t, tc.expected, out) 95 | } 96 | } 97 | } 98 | 99 | func Test_parseIntValue(t *testing.T) { 100 | testCases := []struct { 101 | values url.Values 102 | shouldError bool 103 | expected int 104 | expectedFlag bool 105 | }{ 106 | { 107 | values: url.Values{}, 108 | shouldError: false, 109 | expected: 0, 110 | expectedFlag: false, 111 | }, 112 | { 113 | values: url.Values{"test": []string{"a"}}, 114 | shouldError: true, 115 | expected: 0, 116 | expectedFlag: false, 117 | }, 118 | { 119 | values: url.Values{"test": []string{"123"}}, 120 | shouldError: false, 121 | expected: 123, 122 | expectedFlag: true, 123 | }, 124 | { 125 | values: url.Values{"test": []string{"123.5"}}, 126 | shouldError: true, 127 | expected: 0, 128 | expectedFlag: false, 129 | }, 130 | } 131 | for _, tc := range testCases { 132 | v, ok, err := parseIntValue(tc.values, "test") 133 | if tc.shouldError { 134 | require.Error(t, err) 135 | assert.False(t, ok) 136 | } else { 137 | require.NoError(t, err) 138 | assert.Equal(t, tc.expectedFlag, ok) 139 | assert.Equal(t, tc.expected, v) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package stream_test 2 | 3 | import ( 4 | "io" 5 | "math/rand" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | stream "github.com/GetStream/stream-go2/v8" 15 | ) 16 | 17 | func init() { 18 | rand.Seed(time.Now().UnixNano()) 19 | } 20 | 21 | func newClient(t *testing.T) (*stream.Client, *mockRequester) { 22 | requester := &mockRequester{} 23 | client, err := stream.New("key", "secret", stream.WithHTTPRequester(requester)) 24 | require.NoError(t, err) 25 | return client, requester 26 | } 27 | 28 | type mockRequester struct { 29 | req *http.Request 30 | resp string 31 | } 32 | 33 | func (m *mockRequester) Do(req *http.Request) (*http.Response, error) { 34 | m.req = req 35 | body := "{}" 36 | if m.resp != "" { 37 | body = m.resp 38 | } 39 | return &http.Response{ 40 | StatusCode: http.StatusOK, 41 | Body: io.NopCloser(strings.NewReader(body)), 42 | }, nil 43 | } 44 | 45 | func testRequest(t *testing.T, req *http.Request, method, url, body string) { 46 | assert.Equal(t, url, req.URL.String()) 47 | assert.Equal(t, method, req.Method) 48 | if req.Method == http.MethodPost { 49 | reqBody, err := io.ReadAll(req.Body) 50 | require.NoError(t, err) 51 | assert.JSONEq(t, body, string(reqBody)) 52 | } 53 | headers := req.Header 54 | if headers.Get("X-API-Key") == "" { 55 | assert.NotEmpty(t, headers.Get("Stream-Auth-Type")) 56 | assert.NotEmpty(t, headers.Get("Authorization")) 57 | } 58 | } 59 | 60 | func getTime(t time.Time) stream.Time { 61 | st, _ := time.Parse(stream.TimeLayout, t.Truncate(time.Second).Format(stream.TimeLayout)) 62 | return stream.Time{Time: st} 63 | } 64 | 65 | func newFlatFeedWithUserID(c *stream.Client, userID string) (*stream.FlatFeed, error) { 66 | return c.FlatFeed("flat", userID) 67 | } 68 | 69 | func newAggregatedFeedWithUserID(c *stream.Client, userID string) (*stream.AggregatedFeed, error) { 70 | return c.AggregatedFeed("aggregated", userID) 71 | } 72 | 73 | func newNotificationFeedWithUserID(c *stream.Client, userID string) (*stream.NotificationFeed, error) { 74 | return c.NotificationFeed("notification", userID) 75 | } 76 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | // Version is the current release version for this client 4 | var Version = "v8.8.0" 5 | --------------------------------------------------------------------------------