├── .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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------