├── .ably └── capabilities.yaml ├── .github └── workflows │ ├── check.yml │ ├── docs.yml │ ├── features.yml │ └── integration-test.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── CONTRIBUTING.md ├── COPYRIGHT ├── LICENSE ├── MAINTAINERS.md ├── README.md ├── UPDATING.md ├── ably ├── ably.go ├── ably_test.go ├── auth.go ├── auth_integration_test.go ├── auth_internal_test.go ├── auth_test.go ├── crypto.go ├── crypto_spec_test.go ├── doc.go ├── doc_test.go ├── error.go ├── error_internal_test.go ├── error_names.go ├── error_test.go ├── errors.go ├── event_emitter.go ├── event_emitter_integration_test.go ├── event_emitter_spec_integration_test.go ├── export_test.go ├── generate.go ├── http_paginated_response_integration_test.go ├── internal │ └── ablyutil │ │ ├── idempotent.go │ │ ├── merge.go │ │ ├── msgpack.go │ │ ├── msgpack_test.go │ │ ├── regex.go │ │ ├── regex_test.go │ │ ├── strings.go │ │ ├── strings_test.go │ │ └── time.go ├── logger.go ├── logger_test.go ├── options.go ├── options_test.go ├── os_version_other.go ├── os_version_unix.go ├── os_version_windows.go ├── paginated_result.go ├── proto_action.go ├── proto_channel_options.go ├── proto_channel_options_test.go ├── proto_channel_propeties.go ├── proto_crypto.go ├── proto_error.go ├── proto_http.go ├── proto_message.go ├── proto_message_decoding_test.go ├── proto_message_doc.txt ├── proto_message_integration_test.go ├── proto_message_test.go ├── proto_presence_message.go ├── proto_presence_message_test.go ├── proto_protocol_message.go ├── proto_protocol_message_test.go ├── proto_stat.go ├── proto_types.go ├── proto_types_test.go ├── realtime_channel.go ├── realtime_channel_integration_test.go ├── realtime_channel_internal_test.go ├── realtime_channel_spec_integration_test.go ├── realtime_channel_test.go ├── realtime_client.go ├── realtime_client_integration_test.go ├── realtime_client_internal_test.go ├── realtime_conn.go ├── realtime_conn_integration_test.go ├── realtime_conn_spec_integration_test.go ├── realtime_presence.go ├── realtime_presence_integration_test.go ├── realtime_presence_internal_test.go ├── recovery_context.go ├── recovery_context_test.go ├── rest_channel.go ├── rest_channel_integration_test.go ├── rest_channel_spec_integration_test.go ├── rest_client.go ├── rest_client_integration_test.go ├── rest_client_test.go ├── rest_presence.go ├── rest_presence_spec_integration_test.go ├── state.go ├── token.go ├── transitioner_integration_test.go ├── websocket.go └── websocket_internal_test.go ├── ablytest ├── ablytest.go ├── cryptodata.go ├── fmt.go ├── fmt_test.go ├── logger.go ├── msgpack.go ├── pagination.go ├── proxies.go ├── recorders.go ├── resultgroup.go ├── sandbox.go ├── test_utils.go └── timeout.go ├── examples ├── README.md ├── constants.go ├── realtime │ ├── presence │ │ └── main.go │ └── pub-sub │ │ └── main.go ├── rest │ ├── history │ │ └── main.go │ ├── presence │ │ └── main.go │ ├── publish │ │ └── main.go │ ├── stats │ │ └── main.go │ └── status │ │ └── main.go └── utils.go ├── go.mod ├── go.sum └── scripts ├── ci-generate.sh └── errors └── main.go /.ably/capabilities.yaml: -------------------------------------------------------------------------------- 1 | %YAML 1.2 2 | --- 3 | common-version: 1.2.0 4 | compliance: 5 | Agent Identifier: 6 | Agents: 7 | Operating System: 8 | Authentication: 9 | API Key: 10 | Token: 11 | Callback: 12 | Literal: 13 | URL: 14 | Query Time: 15 | Debugging: 16 | Error Information: 17 | Logs: 18 | Protocol: 19 | JSON: 20 | MessagePack: 21 | Realtime: 22 | Authentication: 23 | Get Confirmed Client Identifier: 24 | Channel: 25 | Attach: 26 | Encryption: 27 | History: 28 | Mode: 29 | Presence: 30 | Enter: 31 | Client: 32 | Get: 33 | History: 34 | Subscribe: 35 | Update: 36 | Client: 37 | Publish: 38 | .caveats: Publishing also causes the client to attach to the channel. 39 | Retry Timeout: 40 | State Events: 41 | Subscribe: 42 | Deltas: 43 | Rewind: 44 | Connection: 45 | Disconnected Retry Timeout: 46 | Get Identifier: 47 | Incremental Backoff: 48 | Lifecycle Control: 49 | Recovery: 50 | State Events: 51 | Suspended Retry Timeout: 52 | Message Echoes: 53 | Message Queuing: 54 | Transport Parameters: 55 | REST: 56 | Authentication: 57 | Authorize: 58 | Create Token Request: 59 | Get Client Identifier: 60 | Request Token: 61 | Channel: 62 | Encryption: 63 | Existence Check: 64 | Get: 65 | History: 66 | Iterate: 67 | Name: 68 | Presence: 69 | History: 70 | Member List: 71 | Publish: 72 | Idempotence: 73 | Parameters for Query String: 74 | Release: 75 | Status: 76 | Channel Details: 77 | Opaque Request: 78 | Request Timeout: 79 | Service: 80 | Get Time: 81 | Statistics: 82 | Query: 83 | Support Hyperlink on Request Failure: 84 | Service: 85 | Environment: 86 | Fallbacks: 87 | Hosts: 88 | Retry Count: 89 | Retry Timeout: 90 | Host: 91 | Testing: 92 | Disable TLS: 93 | TCP Insecure Port: 94 | TCP Secure Port: 95 | Transport: 96 | Connection Open Timeout: 97 | HTTP/2: 98 | Proxy: 99 | .caveats: Can be achieved through specifying a custom HTTP client. 100 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # Based upon: 2 | # https://github.com/actions/starter-workflows/blob/main/ci/go.yml 3 | 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | check: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | go-version: ['1.19', '1.20', '1.21', '1.22'] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | with: 23 | submodules: 'recursive' 24 | 25 | - name: Set up Go ${{ matrix.go-version }} 26 | uses: actions/setup-go@v2 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | 30 | - name: Download Packages 31 | run: go get -t -v ./ably/... 32 | 33 | - name: Format 34 | run: if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then exit 1; fi 35 | 36 | - name: Vet 37 | run: go vet ./ably/... ./scripts/... 38 | 39 | - name: Ensure generated code is up-to-date 40 | run: scripts/ci-generate.sh 41 | 42 | - name: Install go-junit-report 43 | run: go install github.com/jstemmer/go-junit-report@latest 44 | 45 | - name: Unit Tests 46 | run: | 47 | set -o pipefail 48 | go test -v -tags=unit ./... |& tee >(~/go/bin/go-junit-report > unit.junit) 49 | 50 | - name: Upload test results 51 | if: always() 52 | uses: ably/test-observability-action@v1 53 | with: 54 | server-url: 'https://test-observability.herokuapp.com' 55 | server-auth: ${{ secrets.TEST_OBSERVABILITY_SERVER_AUTH_KEY }} 56 | path: '.' 57 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: API Reference 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | deployments: write 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v2 18 | with: 19 | submodules: 'recursive' 20 | 21 | - name: Set up Go 1.19 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: 1.19 25 | 26 | - name: Download Packages 27 | run: | 28 | go install golang.org/x/tools/cmd/godoc@v0.3.0 29 | go install github.com/johnstarich/go/gopages@v0.1.16 30 | 31 | - uses: ably/sdk-upload-action@v2 32 | id: sdk-upload-prempt 33 | with: 34 | mode: preempt 35 | sourcePath: dist 36 | githubToken: ${{ secrets.GITHUB_TOKEN }} 37 | artifactName: godoc 38 | 39 | - name: Build Documentation 40 | run: > 41 | gopages 42 | -source-link "https://github.com/ably/ably-go/blob/${{ github.sha }}/ably/{{slice .Path 5}}{{if .Line}}#L{{.Line}}{{end}}" 43 | -brand-description "Go client library for Ably realtime messaging service." 44 | -brand-title "Ably Go SDK" 45 | -base "${{steps.sdk-upload-prempt.outputs.url-base}}../godoc" 46 | 47 | - name: Configure AWS Credentials 48 | uses: aws-actions/configure-aws-credentials@v1 49 | with: 50 | aws-region: eu-west-2 51 | role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-go 52 | role-session-name: "${{ github.run_id }}-${{ github.run_number }}" 53 | 54 | - name: Upload Documentation 55 | uses: ably/sdk-upload-action@v2 56 | with: 57 | sourcePath: dist 58 | githubToken: ${{ secrets.GITHUB_TOKEN }} 59 | artifactName: godoc 60 | -------------------------------------------------------------------------------- /.github/workflows/features.yml: -------------------------------------------------------------------------------- 1 | name: Features 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | uses: ably/features/.github/workflows/sdk-features.yml@main 12 | with: 13 | repository-name: ably-go 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /.github/workflows/integration-test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | integration-test: 10 | 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | go-version: ['1.19', '1.20', '1.21', '1.22'] 16 | protocol: ['json', 'msgpack'] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | with: 21 | submodules: 'recursive' 22 | 23 | - name: Set up Go ${{ matrix.go-version }} 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: ${{ matrix.go-version }} 27 | 28 | - name: Download Packages 29 | run: go get -t -v ./ably/... 30 | 31 | - name: Install go-junit-report 32 | run: go install github.com/jstemmer/go-junit-report@latest 33 | 34 | - name: Integration Tests with ${{ matrix.protocol }} Protocol 35 | run: | 36 | set -o pipefail 37 | export ABLY_PROTOCOL=${{ matrix.protocol == 'json' && 'application/json' || 'application/x-msgpack' }} 38 | go test -tags=integration -p 1 -race -v -timeout 120m ./... |& tee >(~/go/bin/go-junit-report > ${{ matrix.protocol }}-integration.junit) 39 | 40 | - name: Upload test results 41 | if: always() 42 | uses: ably/test-observability-action@v1 43 | with: 44 | server-url: 'https://test-observability.herokuapp.com' 45 | server-auth: ${{ secrets.TEST_OBSERVABILITY_SERVER_AUTH_KEY }} 46 | path: '.' 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Output of the go coverage tool, specifically when used with LiteIDE 9 | *.out 10 | 11 | # Dependency directories (remove the comment below to include it) 12 | vendor/ 13 | .idea/ 14 | *.env 15 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "common"] 2 | path = common 3 | url = https://github.com/ably/ably-common.git 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to Ably Go SDK 2 | 3 | Because this package uses `internal` packages, all fork development has to happen under `$GOPATH/src/github.com/ably/ably-go` to prevent `use of internal package not allowed` errors. 4 | 5 | 1. Fork `github.com/ably/ably-go` 6 | 2. go to the `ably-go` directory: `cd $GOPATH/src/github.com/ably/ably-go` 7 | 3. add your fork as a remote: `git remote add fork git@github.com:your-username/ably-go` 8 | 4. create your feature branch: `git checkout -b my-new-feature` 9 | 5. commit your changes (`git commit -am 'Add some feature'`) 10 | 6. ensure you have added suitable tests and the test suite is passing for both JSON and MessagePack protocols (see the Running Tests section below for more details about tests including the commands to run them). 11 | 7. push to the branch: `git push fork my-new-feature` 12 | 8. create a new Pull Request. 13 | 14 | ### Previewing Godoc Locally 15 | 16 | 1. Install [godoc](https://pkg.go.dev/golang.org/x/tools/cmd/godoc) globally via `go get` and run at root 17 | 18 | ```bash 19 | godoc -http=:8000 20 | ``` 21 | - Open the link http://localhost:8000/ for viewing the documentation. 22 | 23 | 2. Export `godoc` using latest version of [gopages](https://pkg.go.dev/github.com/johnstarich/go/gopages#section-readme) 24 | ```bash 25 | gopages -brand-description "Go client library for Ably realtime messaging service." -brand-title "Ably Go SDK" 26 | ``` 27 | - `godoc html` is exported to `dist` and can be served using `python3 http.server` 28 | 29 | ```bash 30 | cd dist 31 | py -m http.server 8000 32 | ``` 33 | - Open the link http://localhost:8000/ for viewing the documentation. 34 | 35 | ### Running Tests 36 | 37 | This project contains two types of test. Test which use the `ablytest` package and tests which dont. 38 | 39 | At each stage of the CI pipeline, test results are uploaded to a [test observability server](https://github.com/ably-labs/test-observability-server) 40 | 41 | The tests which don't make use of the `ablytest` package are considered unit tests. These tests exist in files which are suffixed `_test.go`. They run in the CI pipeline at the step `Unit Tests`. They can be run locally with the command: 42 | 43 | ``` 44 | go test -v -tags=unit ./... 45 | ``` 46 | 47 | When adding new unit tests, the following build tag must be included at the top of the file to exclude these tests from running in CI as part of the Integration test step. 48 | 49 | ``` 50 | //go:build !integration 51 | // +build !integration 52 | ``` 53 | 54 | #### Integration tests 55 | 56 | The tests which use the package `ablytest` are considered integration tests. These tests take longer to run than the unit tests and are mostly run in a sandbox environment. They are dependent on the sandbox environment being available and will fail if the environment is experiencing issues. There are some known issues for random failures in a few of the tests, so some of these tests may fail unexpectedly from time to time. 57 | 58 | Please note that these tests are not true integration tests as rather than using the public API, they rely on `export_test.go` to expose private functionality so that it can be tested. These tests exist in files which are suffixed `_integration_test.go`. They run twice in the CI pipeline at the steps `Integration Tests with JSON Protocol` and `Integration Tests with MessagePack Protocol`. To run these tests locally, they have a dependency on a git submodule being present, so it is necessary clone the project with: 59 | 60 | ``` 61 | git clone git@github.com:ably/ably-go.git --recurse-submodules 62 | ``` 63 | 64 | The tests can be run with the commands: 65 | 66 | ``` 67 | export ABLY_PROTOCOL="application/json" && go test -tags=integration -p 1 -race -v -timeout 120m ./... 68 | export ABLY_PROTOCOL="application/x-msgpack" && go test -tags=integration -p 1 -race -v -timeout 120m ./... 69 | ``` 70 | 71 | Depending on which protocol they are to be run for. It is also necessary to clean the test cache in between runs of these tests which can be done with the command: 72 | 73 | ``` 74 | go clean -testcache 75 | ``` 76 | 77 | When adding new integration tests, the following build tag must be included at the top of the file to exclude these tests for running in CI as part of the Unit test step. 78 | 79 | ``` 80 | //go:build !unit 81 | // +build !unit 82 | ``` 83 | 84 | ### Release process 85 | 86 | Starting with release 1.2, this library uses [semantic versioning](http://semver.org/). For each release, the following needs to be done: 87 | 88 | 1. Create a branch for the release, named like `release/1.2.3` (where `1.2.3` is the new version number) 89 | 2. Replace all references of the current version number with the new version number and commit the changes 90 | 3. Run [`github_changelog_generator`](https://github.com/github-changelog-generator/github-changelog-generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). This may require some manual intervention, both in terms of how the command is run and how the change log file is modified. Your mileage may vary: 91 | - The command you will need to run will look something like this: `github_changelog_generator -u ably -p ably-go --since-tag v1.2.3 --output delta.md --token $GITHUB_TOKEN_WITH_REPO_ACCESS`. Generate token [here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token). 92 | - Using the command above, `--output delta.md` writes changes made after `--since-tag` to a new file 93 | - The contents of that new file (`delta.md`) then need to be manually inserted at the top of the `CHANGELOG.md`, changing the "Unreleased" heading and linking with the current version numbers 94 | - Also ensure that the "Full Changelog" link points to the new version tag instead of the `HEAD` 95 | 4. Commit this change: `git add CHANGELOG.md && git commit -m "Update change log."` 96 | 5. Make a PR against `main` 97 | 6. Once the PR is approved, merge it into `main` 98 | 7. Add a tag to the new `main` head commit and push to origin such as `git tag v1.2.3 && git push origin v1.2.3` 99 | 8. Create the release on Github, from the new tag, including populating the release notes 100 | 9. Create the entry on the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)) 101 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Copyright 2015-2022 Ably Real-time Ltd (ably.com) 2 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | This repository is owned by the Ably SDK team. 2 | -------------------------------------------------------------------------------- /UPDATING.md: -------------------------------------------------------------------------------- 1 | # Upgrade / Migration Guide 2 | 3 | ## Version 1.1.5 to 1.2.0 4 | 5 | We have made many **breaking changes** in the version 1.2 release of this SDK. 6 | 7 | In this guide we aim to highlight the main differences you will encounter when migrating your code from the interfaces we were offering prior to the [version 1.2.0 release](https://github.com/ably/ably-go/releases/tag/v1.2.0). 8 | 9 | These include: 10 | 11 | - Changes to numerous function and method signatures - these are breaking changes, in that your code will need to change to use them in their new form 12 | - Adoption of the [Context Concurrency Pattern](https://blog.golang.org/context) using the [context package](https://pkg.go.dev/context) - e.g. [ably.RealtimeChannel.Subscribe](https://pkg.go.dev/github.com/ably/ably-go/ably#RealtimeChannel.Subscribe) 13 | - Adoption of the [Functional Options Pattern](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) - e.g. [ably.ClientOption](https://pkg.go.dev/github.com/ably/ably-go/ably#ClientOption) 14 | 15 | ### Asynchronous Operations and the `Context` Concurrency Pattern 16 | 17 | We've now moved to a model where methods are blocking in nature, cancellable with a [Context](https://pkg.go.dev/context#Context). 18 | 19 | We use `ctx` in examples to refer to a `Context` instance that you create in your code. 20 | 21 | For robust, production-ready applications you will rarely (actually, probably _never_) want to create your `Context` using the basic [Background](https://pkg.go.dev/context#Background) function, because it cannot be cancelled and remains for the lifecycle of the program. Instead, you should use [WithTimeout](https://pkg.go.dev/context#WithTimeout) or [WithDeadline](https://pkg.go.dev/context#WithDeadline). 22 | 23 | For example, a context can be created with a 10-second timeout like this: 24 | 25 | ```go 26 | context.WithTimeout(context.Background(), 10 * time.Second) 27 | ``` 28 | 29 | Adding the necessary code to stay on top of cancellation, you will need something like this: 30 | 31 | ```go 32 | ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second) 33 | defer cancel() 34 | ``` 35 | 36 | This way, the context can be cancelled at the close of the function. 37 | 38 | **Note**: 39 | For Realtime operations, cancellation isn't supported by the underlying protocol. 40 | A context cancellation in this case just means that the call returns immediately, without waiting for the operation to finish, with the error provided by the context; but the operation carries on in the background. 41 | Timeouts for these operations are handled separately, with [a configurable realtime request timeout duration](https://pkg.go.dev/github.com/ably/ably-go/ably#WithRealtimeRequestTimeout). 42 | 43 | ### Client Options now has a Functional Interface 44 | 45 | Before version 1.2.0, you instantiated a client using a `ClientOptions` instance created with the `NewClientOptions` function: 46 | 47 | ```go 48 | client, err := ably.NewRealtime(ably.NewClientOptions("xxx:xxx")) 49 | ``` 50 | 51 | **Starting with version 1.2.0**, you must use the new functional interface: 52 | 53 | ```go 54 | client, err := ably.NewRealtime(ably.WithKey("xxx:xxx")) 55 | ``` 56 | 57 | For a full list of client options, see all functions prefixed by `With` that return a `ClientOption` function 58 | [in the API reference](https://pkg.go.dev/github.com/ably/ably-go/ably#ClientOption). 59 | 60 | ### Subscription now uses Message Handlers 61 | 62 | Before version 1.2.0, you subscribed to receive all messages from a Go channel like this: 63 | 64 | ```go 65 | sub, _ := channel.Subscribe() 66 | for msg := range sub.MessageChannel() { 67 | fmt.Println("Received message: ", msg) 68 | } 69 | ``` 70 | 71 | **Starting with version 1.2.0**, you must supply a context and your own message handler function to the new [SubscribeAll](https://pkg.go.dev/github.com/ably/ably-go/ably#RealtimeChannel.SubscribeAll) method: 72 | 73 | ```go 74 | unsubscribe, _ := channel.SubscribeAll(ctx, func(msg *ably.Message) { 75 | fmt.Println("Received message: ", msg) 76 | }) 77 | ``` 78 | 79 | The signature of the [Subscribe](https://pkg.go.dev/github.com/ably/ably-go/ably#RealtimeChannel.Subscribe) method has also changed. It now requires a [Context](https://pkg.go.dev/context#Context) as well as the channel name and your message handler. 80 | 81 | Both `Subscribe` and `SubscribeAll` are now blocking methods. 82 | 83 | Detail around the concurrency and routing of calls to message and event handlers is described in the package documentation 84 | under [Event Emitters](https://pkg.go.dev/github.com/ably/ably-go/ably#hdr-Event_Emitters). 85 | 86 | ### The `Publish` Method now Blocks 87 | 88 | Before version 1.2.0, you published messages to a channel by calling the `Publish` method and then waiting for the `Result`: 89 | 90 | ```go 91 | result, _ := channel.Publish("EventName1", "EventData1") 92 | 93 | // block until the publish operation completes 94 | result.Wait() 95 | ``` 96 | 97 | **Starting with version 1.2.0**, you must supply a context, as this method is now blocking: 98 | 99 | ```go 100 | // block until the publish operation completes or is cancelled 101 | err := channel.Publish(ctx, "EventName1", "EventData1") 102 | ``` 103 | 104 | ### Querying History 105 | 106 | Before version 1.2.0, you queried history as shown below, receiving a `PaginatedResult` from the `History` function: 107 | 108 | ```go 109 | page, err := channel.History(nil) 110 | for ; err == nil; page, err = page.Next() { 111 | for _, message := range page.Messages() { 112 | fmt.Println("Message from History: ", message) 113 | } 114 | } 115 | if err != nil { 116 | panic(err) 117 | } 118 | ``` 119 | 120 | **Starting with version 1.2.0**, you must first call `History`, which can be passed [functional options](https://pkg.go.dev/github.com/ably/ably-go/ably#HistoryOption), and then use the [Pages](https://pkg.go.dev/github.com/ably/ably-go/ably#HistoryRequest.Pages) (or [Items](https://pkg.go.dev/github.com/ably/ably-go/ably#HistoryRequest.Items), if you prefer) method on the returned `HistoryRequest` instance 121 | 122 | ```go 123 | pages, err := channel.History().Pages(ctx) 124 | if err != nil { 125 | panic(err) 126 | } 127 | for pages.Next(ctx) { 128 | for _, message := range pages.Items() { 129 | fmt.Println("Message from History: ", message) 130 | } 131 | } 132 | if err := pages.Err(); err != nil { 133 | panic(err) 134 | } 135 | ``` 136 | -------------------------------------------------------------------------------- /ably/ably.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "time" 7 | ) 8 | 9 | func min(i, j int) int { 10 | if i < j { 11 | return i 12 | } 13 | return j 14 | } 15 | 16 | func max(i, j int) int { 17 | if i > j { 18 | return i 19 | } 20 | return j 21 | } 22 | 23 | func nonil(err ...error) error { 24 | for _, err := range err { 25 | if err != nil { 26 | return err 27 | } 28 | } 29 | return nil 30 | } 31 | 32 | func nonempty(s ...string) string { 33 | for _, s := range s { 34 | if s != "" { 35 | return s 36 | } 37 | } 38 | return "" 39 | } 40 | 41 | func randomString(n int) string { 42 | p := make([]byte, n/2+1) 43 | rand.Read(p) 44 | return hex.EncodeToString(p)[:n] 45 | } 46 | 47 | // unixMilli returns the given time as a timestamp in milliseconds since epoch. 48 | func unixMilli(t time.Time) int64 { 49 | return t.UnixNano() / int64(time.Millisecond) 50 | } 51 | -------------------------------------------------------------------------------- /ably/auth_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "testing" 10 | "time" 11 | 12 | "github.com/ably/ably-go/ably" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestAuth_TimestampRSA10k(t *testing.T) { 18 | now, err := time.Parse(time.RFC822, time.RFC822) 19 | assert.NoError(t, err) 20 | 21 | t.Run("must use local time when UseQueryTime is false", func(t *testing.T) { 22 | 23 | rest, _ := ably.NewREST( 24 | ably.WithKey("fake:key"), 25 | ably.WithNow(func() time.Time { 26 | return now 27 | })) 28 | a := rest.Auth 29 | a.SetServerTimeFunc(func() (time.Time, error) { 30 | return now.Add(time.Minute), nil 31 | }) 32 | stamp, err := a.Timestamp(context.Background(), false) 33 | assert.NoError(t, err) 34 | assert.True(t, stamp.Equal(now), 35 | "expected %s got %s", now, stamp) 36 | }) 37 | t.Run("must use server time when UseQueryTime is true", func(t *testing.T) { 38 | 39 | rest, _ := ably.NewREST( 40 | ably.WithKey("fake:key"), 41 | ably.WithNow(func() time.Time { 42 | return now 43 | })) 44 | a := rest.Auth 45 | a.SetServerTimeFunc(func() (time.Time, error) { 46 | return now.Add(time.Minute), nil 47 | }) 48 | stamp, err := rest.Timestamp(true) 49 | assert.NoError(t, err) 50 | serverTime := now.Add(time.Minute) 51 | assert.True(t, stamp.Equal(serverTime), 52 | "expected %s got %s", serverTime, stamp) 53 | }) 54 | t.Run("must use server time offset ", func(t *testing.T) { 55 | 56 | now := now 57 | rest, _ := ably.NewREST( 58 | ably.WithKey("fake:key"), 59 | ably.WithNow(func() time.Time { 60 | return now 61 | })) 62 | a := rest.Auth 63 | a.SetServerTimeFunc(func() (time.Time, error) { 64 | return now.Add(time.Minute), nil 65 | }) 66 | stamp, err := rest.Timestamp(true) 67 | assert.NoError(t, err) 68 | serverTime := now.Add(time.Minute) 69 | assert.True(t, stamp.Equal(serverTime), 70 | "expected %s got %s", serverTime, stamp) 71 | 72 | now = now.Add(time.Minute) 73 | a.SetServerTimeFunc(func() (time.Time, error) { 74 | return time.Time{}, errors.New("must not be called") 75 | }) 76 | stamp, err = rest.Timestamp(true) 77 | assert.NoError(t, err) 78 | serverTime = now.Add(time.Minute) 79 | assert.True(t, stamp.Equal(serverTime), 80 | "expected %s got %s", serverTime, stamp) 81 | }) 82 | } 83 | 84 | func TestAuth_ClientID_Error(t *testing.T) { 85 | opts := []ably.ClientOption{ 86 | ably.WithClientID("*"), 87 | ably.WithKey("abc:abc"), 88 | ably.WithUseTokenAuth(true), 89 | } 90 | _, err := ably.NewRealtime(opts...) 91 | err = checkError(40102, err) 92 | assert.NoError(t, err) 93 | } 94 | -------------------------------------------------------------------------------- /ably/crypto.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "crypto/rand" 5 | "errors" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | // Crypto contains the properties required to configure the encryption of [ably.Message] payloads. 11 | var Crypto struct { 12 | 13 | // GenerateRandomKey returns a random key (as a binary/a byte array) to be used in the encryption of the channel. 14 | // If the language cryptographic randomness primitives are blocking or async, a callback is used. 15 | // The callback returns a generated binary key. 16 | // keyLength is passed as a param. It is a length of the key, in bits, to be generated. 17 | // If not specified, this is equal to the default keyLength of the default algorithm: 18 | // for AES this is 256 bits (RSE2). 19 | GenerateRandomKey func(keyLength int) ([]byte, error) 20 | 21 | // GetDefaultParams returns a [ably.CipherParams] object, using the default values for any fields 22 | // not supplied by the [ably.CipherParamOptions] object. The Key field must be provided (RSE1). 23 | GetDefaultParams func(CipherParams) CipherParams 24 | } 25 | 26 | func init() { 27 | Crypto.GenerateRandomKey = generateRandomKey 28 | Crypto.GetDefaultParams = defaultCipherParams 29 | } 30 | 31 | func generateRandomKey(keyLength int) ([]byte, error) { 32 | if keyLength <= 0 { 33 | keyLength = defaultCipherKeyLength 34 | } 35 | key := make([]byte, keyLength/8) 36 | if _, err := io.ReadFull(rand.Reader, key); err != nil { 37 | return nil, err 38 | } 39 | return key, nil 40 | } 41 | 42 | func defaultCipherParams(c CipherParams) CipherParams { 43 | if len(c.Key) == 0 { 44 | panic(errors.New("cipher key must be provided")) 45 | } 46 | if c.Algorithm == 0 { 47 | c.Algorithm = defaultCipherAlgorithm 48 | } 49 | c.KeyLength = len(c.Key) * 8 50 | if !c.Algorithm.isValidKeyLength(c.KeyLength) { 51 | panic(fmt.Sprintf("invalid key length for algorithm %v: %d", c.Algorithm, c.KeyLength)) 52 | } 53 | if c.Mode == 0 { 54 | c.Mode = defaultCipherMode 55 | } 56 | return c 57 | } 58 | -------------------------------------------------------------------------------- /ably/crypto_spec_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "crypto/aes" 8 | "testing" 9 | 10 | "github.com/ably/ably-go/ably" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestCrypto_RSE1_GetDefaultParams(t *testing.T) { 16 | 17 | for _, c := range []struct { 18 | name string 19 | in ably.CipherParams 20 | expected ably.CipherParams 21 | expectedPanic bool 22 | }{ 23 | { 24 | name: "RSE1a, RSE1b, RSE1d: sets defaults", 25 | in: ably.CipherParams{ 26 | Key: make([]byte, 256/8), 27 | }, 28 | expected: ably.CipherParams{ 29 | Key: make([]byte, 256/8), 30 | KeyLength: 256, 31 | Algorithm: ably.CipherAES, 32 | Mode: ably.CipherCBC, 33 | }, 34 | }, 35 | { 36 | name: "RSE1b: no key panics", 37 | in: ably.CipherParams{ 38 | Algorithm: ably.CipherAES, 39 | Mode: ably.CipherCBC, 40 | }, 41 | expectedPanic: true, 42 | }, 43 | { 44 | name: "RSE1e: wrong key length panics (AES 256)", 45 | in: ably.CipherParams{ 46 | Key: make([]byte, 256/8-1), 47 | }, 48 | expectedPanic: true, 49 | }, 50 | { 51 | name: "RSE1e: valid key length works (AES 128)", 52 | in: ably.CipherParams{ 53 | Key: make([]byte, 128/8), 54 | }, 55 | expected: ably.CipherParams{ 56 | Key: make([]byte, 128/8), 57 | KeyLength: 128, 58 | Algorithm: ably.CipherAES, 59 | Mode: ably.CipherCBC, 60 | }, 61 | }, 62 | } { 63 | c := c 64 | t.Run(c.name, func(t *testing.T) { 65 | 66 | defer func() { 67 | r := recover() 68 | if r != nil && !c.expectedPanic { 69 | panic(r) 70 | } else if r == nil && c.expectedPanic { 71 | t.Fatal("expected panic") 72 | } 73 | }() 74 | 75 | got := ably.Crypto.GetDefaultParams(c.in) 76 | assert.Equal(t, c.expected, got, 77 | "expected: %#v; got: %#v", c.expected, got) 78 | }) 79 | } 80 | } 81 | 82 | func TestCrypto_RSE2_GenerateRandomKey(t *testing.T) { 83 | t.Run("must use default key length", func(ts *testing.T) { 84 | key, err := ably.Crypto.GenerateRandomKey(0) 85 | assert.NoError(ts, err) 86 | bitCount := len(key) * 8 87 | assert.Equal(ts, ably.DefaultCipherKeyLength, bitCount, 88 | "expected %d got %d", ably.DefaultCipherKeyLength, bitCount) 89 | }) 90 | t.Run("must use optional key length", func(ts *testing.T) { 91 | keyLength := 128 92 | key, err := ably.Crypto.GenerateRandomKey(keyLength) 93 | assert.NoError(ts, err) 94 | bitCount := len(key) * 8 95 | assert.Equal(ts, 128, bitCount, 96 | "expected %d got %d", keyLength, bitCount) 97 | }) 98 | } 99 | 100 | func Test_Issue330_IVReuse(t *testing.T) { 101 | 102 | params, err := ably.DefaultCipherParams() 103 | assert.NoError(t, err) 104 | cipher, err := ably.NewCBCCipher(*params) 105 | assert.NoError(t, err) 106 | cipherText1, err := cipher.Encrypt([]byte("foo")) 107 | assert.NoError(t, err) 108 | cipherText2, err := cipher.Encrypt([]byte("foo")) 109 | assert.NoError(t, err) 110 | iv1 := string(cipherText1[:aes.BlockSize]) 111 | iv2 := string(cipherText2[:aes.BlockSize]) 112 | assert.NotEqual(t, iv1, iv2, "IV shouldn't be reused") 113 | } 114 | -------------------------------------------------------------------------------- /ably/doc.go: -------------------------------------------------------------------------------- 1 | // Package ably 2 | // 3 | // # Ably Go Client Library SDK API Reference 4 | // 5 | // The Go Client Library SDK supports a realtime and a REST interface. The Go API references are generated from the [Ably Go Client Library SDK source code] using [godoc] and structured by classes. 6 | // 7 | // The realtime interface enables a client to maintain a persistent connection to Ably and publish, subscribe and be present on channels. The REST interface is stateless and typically implemented server-side. It is used to make requests such as retrieving statistics, token authentication and publishing to a channel. 8 | // 9 | // View the [Ably docs] for conceptual information on using Ably, and for API references featuring all languages. The combined [API references] are organized by features and split between the [realtime] and [REST] interfaces. 10 | // 11 | // Get started at https://github.com/ably/ably-go#using-the-realtime-api. 12 | // 13 | // # Event Emitters 14 | // 15 | // An event emitter pattern appears in multiple places in the library. 16 | // 17 | // The On method takes an event type identifier and a handler function to be 18 | // called with the event's associated data whenever an event of that type is 19 | // emitted in the event emitter. It also returns an "off" function to undo this 20 | // operation, so that the handler function isn't called anymore. 21 | // 22 | // The OnAll method is like On, but for events of all types. 23 | // 24 | // The Once method works like On, except the handler is just called once, for 25 | // the first matching event. 26 | // 27 | // OnceAll is like OnAll in the same way Once is like On. 28 | // 29 | // The Off method is like calling the "off" function returned by calls to On and 30 | // Once methods with a matching event type identifier. 31 | // 32 | // The OffAll method is like Off, except it is like calling all the "off" 33 | // functions. 34 | // 35 | // Each handler is assigned its own sequential queue of events. That is, any 36 | // given handler function will not receive calls from different goroutines that 37 | // run concurrently; you can count on the next call to a handler to happen 38 | // after the previous call has returned, and you can count on events or 39 | // messages to be delivered to the handler in the same order they were emitted. 40 | // Different handlers may be called concurrently, though. 41 | // 42 | // Calling any of these methods an "off" function inside a handler will only 43 | // have effect for subsequent events. 44 | // 45 | // For messages and presence messages, "on" is called "subscribe" and "off" is 46 | // called "unsubscribe". 47 | // 48 | // # Paginated results 49 | // 50 | // Most requests to the Ably REST API return a single page of results, with 51 | // hyperlinks to the first and next pages in the whole collection of results. 52 | // To facilitate navigating through these pages, the library provides access to 53 | // such paginated results though a common pattern. 54 | // 55 | // A method that prepares a paginated request returns a Request object with two 56 | // methods: Pages and Items. Pages returns a PaginatedResult, an iterator that, 57 | // on each iteration, yields a whole page of results. Items is simply a 58 | // convenience wrapper that yields single results instead. 59 | // 60 | // In both cases, calling the method validates the request and may return an 61 | // error. 62 | // 63 | // Then, for accessing the results, the Next method from the resulting 64 | // iterator object must be called repeatedly; each time it returns true, the 65 | // result that has been retrieved can be inspected with the Items or Item method 66 | // from the iterator object. Finally, once it returns false, the Err method must 67 | // be called to check if the iterator stopped due to some error, or else, it 68 | // just finished going through all pages. 69 | // 70 | // Calling the First method on the PaginatedResults returns the first page of the 71 | // results. However, the Next method has to be called before inspecting the items. 72 | // 73 | // For every page in the PaginatedResults, the HasNext method can be called to check if there 74 | // are more page(s) available. IsLast method checks if the page is the 75 | // last page. Both methods return a true or false value. 76 | // 77 | // See the PaginatedResults example. 78 | // 79 | // [Ably Go Client Library SDK source code]: https://github.com/ably/ably-go/ 80 | // [godoc]: https://pkg.go.dev/golang.org/x/tools/cmd/godoc 81 | // [Ably docs]: https://ably.com/docs/ 82 | // [API references]: https://ably.com/docs/api/ 83 | // [realtime]: https://ably.com/docs/api/realtime-sdk?lang=go 84 | // [REST]: https://ably.com/docs/api/rest-sdk?lang=go 85 | package ably 86 | -------------------------------------------------------------------------------- /ably/doc_test.go: -------------------------------------------------------------------------------- 1 | package ably_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "context" 7 | "github.com/ably/ably-go/ably" 8 | ) 9 | 10 | func Example_paginatedResults() { 11 | ctx := context.Background() 12 | client, err := ably.NewRealtime(ably.WithKey("xxx:xxx")) 13 | if err != nil { 14 | panic(err) 15 | } 16 | channel := client.Channels.Get("persisted:test") 17 | 18 | err = channel.Publish(ctx, "EventName1", "EventData1") 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | pages, err := channel.History().Pages(ctx) 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | // Returning and iterating over the first page 29 | pages.Next(ctx) 30 | pages.First(ctx) 31 | for _, message := range pages.Items() { 32 | fmt.Println(message) 33 | } 34 | 35 | // Iteration over pages in PaginatedResult 36 | for pages.Next(ctx) { 37 | fmt.Println(pages.HasNext(ctx)) 38 | fmt.Println(pages.IsLast(ctx)) 39 | for _, presence := range pages.Items() { 40 | fmt.Println(presence) 41 | } 42 | } 43 | if err := pages.Err(); err != nil { 44 | panic(err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ably/error.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "mime" 8 | "net" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | func (code ErrorCode) toStatusCode() int { 14 | switch status := int(code) / 100; status { 15 | case 16 | http.StatusBadRequest, 17 | http.StatusUnauthorized, 18 | http.StatusForbidden, 19 | http.StatusNotFound, 20 | http.StatusMethodNotAllowed, 21 | http.StatusInternalServerError: 22 | return status 23 | default: 24 | return 0 25 | } 26 | } 27 | 28 | func codeFromStatus(statusCode int) ErrorCode { 29 | switch code := ErrorCode(statusCode * 100); code { 30 | case 31 | ErrBadRequest, 32 | ErrUnauthorized, 33 | ErrForbidden, 34 | ErrNotFound, 35 | ErrMethodNotAllowed, 36 | ErrInternalError: 37 | return code 38 | default: 39 | return ErrNotSet 40 | } 41 | } 42 | 43 | // ErrorInfo is a generic Ably error object that contains an Ably-specific status code, and a generic status code. 44 | // Errors returned from the Ably server are compatible with the ErrorInfo structure and should result in errors 45 | // that inherit from ErrorInfo. It may contain underlying error value which caused the failure. 46 | type ErrorInfo struct { 47 | // StatusCode is a http Status code corresponding to this error, where applicable (TI1). 48 | StatusCode int 49 | // Code is the standard [ably error code] 50 | // [ably error code]: https://github.com/ably/ably-common/blob/main/protocol/errors.json (TI1). 51 | Code ErrorCode 52 | // HRef is included for REST responses to provide a URL for additional help on the error code (TI4). 53 | HRef string 54 | // Cause provides Information pertaining to what caused the error where available (TI1). 55 | Cause *ErrorInfo 56 | // err is the application-level error we're wrapping, or just a message. 57 | // If Cause is non-nil, err == *Cause. 58 | err error 59 | } 60 | 61 | // Error implements the builtin error interface. 62 | func (e ErrorInfo) Error() string { 63 | errorHref := e.HRef 64 | if errorHref == "" && e.Code != 0 { 65 | errorHref = fmt.Sprintf("https://help.ably.io/error/%d", e.Code) 66 | } 67 | msg := e.Message() 68 | var see string 69 | if !strings.Contains(msg, errorHref) { 70 | see = " See " + errorHref 71 | } 72 | return fmt.Sprintf("[ErrorInfo :%s code=%d %[2]v statusCode=%d]%s", msg, e.Code, e.StatusCode, see) 73 | } 74 | 75 | // Unwrap implements the implicit interface that errors.Unwrap understands. 76 | func (e ErrorInfo) Unwrap() error { 77 | return e.err 78 | } 79 | 80 | // Message returns the undecorated error message. 81 | func (e ErrorInfo) Message() string { 82 | if e.err == nil { 83 | return "" 84 | } 85 | return e.err.Error() 86 | } 87 | 88 | func newError(defaultCode ErrorCode, err error) *ErrorInfo { 89 | switch err := err.(type) { 90 | case *ErrorInfo: 91 | return err 92 | case net.Error: 93 | if err.Timeout() { 94 | return &ErrorInfo{Code: ErrTimeoutError, StatusCode: 500, err: err} 95 | } 96 | } 97 | return &ErrorInfo{ 98 | Code: defaultCode, 99 | StatusCode: defaultCode.toStatusCode(), 100 | 101 | err: err, 102 | } 103 | } 104 | 105 | func newErrorf(code ErrorCode, format string, v ...interface{}) *ErrorInfo { 106 | return newError(code, fmt.Errorf(format, v...)) 107 | } 108 | 109 | func newErrorFromProto(err *errorInfo) *ErrorInfo { 110 | if err == nil { 111 | return nil 112 | } 113 | return &ErrorInfo{ 114 | Code: ErrorCode(err.Code), 115 | StatusCode: err.StatusCode, 116 | HRef: err.HRef, 117 | err: errors.New(err.Message), 118 | } 119 | } 120 | 121 | func (e *ErrorInfo) unwrapNil() error { 122 | if e == nil { 123 | return nil 124 | } 125 | return e 126 | } 127 | 128 | func code(err error) ErrorCode { 129 | if e, ok := err.(*ErrorInfo); ok { 130 | return e.Code 131 | } 132 | return ErrNotSet 133 | } 134 | 135 | func statusCode(err error) int { 136 | if e, ok := err.(*ErrorInfo); ok { 137 | return e.StatusCode 138 | } 139 | return 0 140 | } 141 | 142 | func errFromUnprocessableBody(resp *http.Response) error { 143 | errMsg, err := io.ReadAll(resp.Body) 144 | if err == nil { 145 | err = errors.New(string(errMsg)) 146 | } 147 | return &ErrorInfo{Code: ErrBadRequest, StatusCode: resp.StatusCode, err: err} 148 | } 149 | 150 | func isTimeoutOrDnsErr(err error) bool { 151 | var netErr net.Error 152 | if errors.As(err, &netErr) { 153 | if netErr.Timeout() { // RSC15l2 154 | return true 155 | } 156 | } 157 | var dnsErr *net.DNSError 158 | return errors.As(err, &dnsErr) // RSC15l1 159 | } 160 | 161 | func checkValidHTTPResponse(resp *http.Response) error { 162 | type errorBody struct { 163 | Error errorInfo `json:"error,omitempty" codec:"error,omitempty"` 164 | } 165 | if resp.StatusCode < 300 { 166 | return nil 167 | } 168 | defer resp.Body.Close() 169 | typ, _, mimeErr := mime.ParseMediaType(resp.Header.Get("Content-Type")) 170 | if mimeErr != nil { 171 | return &ErrorInfo{ 172 | Code: 50000, 173 | StatusCode: resp.StatusCode, 174 | err: mimeErr, 175 | } 176 | } 177 | if typ != protocolJSON && typ != protocolMsgPack { 178 | return errFromUnprocessableBody(resp) 179 | } 180 | 181 | body := &errorBody{} 182 | if err := decode(typ, resp.Body, &body); err != nil { 183 | return newError(codeFromStatus(resp.StatusCode), errors.New(http.StatusText(resp.StatusCode))) 184 | } 185 | 186 | err := newErrorFromProto(&body.Error) 187 | if body.Error.Message != "" { 188 | err.err = errors.New(body.Error.Message) 189 | } 190 | if err.Code == 0 && err.StatusCode == 0 { 191 | err.Code, err.StatusCode = codeFromStatus(resp.StatusCode), resp.StatusCode 192 | } 193 | return err 194 | } 195 | -------------------------------------------------------------------------------- /ably/error_names.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | // Error constants used in ably-go. 4 | // These used to be generated from the error description text. 5 | // But this means that the error name changes if we update the text, and that some names are too long 6 | 7 | const ( 8 | ErrNotSet ErrorCode = 0 9 | ErrBadRequest ErrorCode = 40000 10 | ErrInvalidCredential ErrorCode = 40005 11 | ErrInvalidClientID ErrorCode = 40012 12 | ErrUnauthorized ErrorCode = 40100 13 | ErrInvalidCredentials ErrorCode = 40101 14 | ErrIncompatibleCredentials ErrorCode = 40102 15 | ErrInvalidUseOfBasicAuthOverNonTLSTransport ErrorCode = 40103 16 | ErrTokenErrorUnspecified ErrorCode = 40140 17 | ErrErrorFromClientTokenCallback ErrorCode = 40170 18 | ErrForbidden ErrorCode = 40300 19 | ErrNotFound ErrorCode = 40400 20 | ErrMethodNotAllowed ErrorCode = 40500 21 | ErrInternalError ErrorCode = 50000 22 | ErrInternalChannelError ErrorCode = 50001 23 | ErrInternalConnectionError ErrorCode = 50002 24 | ErrTimeoutError ErrorCode = 50003 25 | ErrConnectionFailed ErrorCode = 80000 26 | ErrConnectionSuspended ErrorCode = 80002 27 | ErrConnectionClosed ErrorCode = 80017 28 | ErrDisconnected ErrorCode = 80003 29 | ErrProtocolError ErrorCode = 80013 30 | ErrChannelOperationFailed ErrorCode = 90000 31 | ErrChannelOperationFailedInvalidChannelState ErrorCode = 90001 32 | ) 33 | -------------------------------------------------------------------------------- /ably/error_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "net" 11 | "net/http" 12 | "net/http/httptest" 13 | "net/url" 14 | "strconv" 15 | "strings" 16 | "testing" 17 | 18 | "github.com/ably/ably-go/ably" 19 | "github.com/stretchr/testify/assert" 20 | ) 21 | 22 | func TestErrorResponseWithInvalidKey(t *testing.T) { 23 | opts := []ably.ClientOption{ably.WithKey(":")} 24 | _, e := ably.NewREST(opts...) 25 | assert.Error(t, e, 26 | "NewREST(): expected err != nil") 27 | err, ok := e.(*ably.ErrorInfo) 28 | assert.True(t, ok, 29 | "want e be *ably.Error; was %T", e) 30 | assert.Equal(t, 400, err.StatusCode, 31 | "want StatusCode=400; got %d", err.StatusCode) 32 | assert.Equal(t, ably.ErrInvalidCredential, err.Code, 33 | "want Code=40005; got %d", err.Code) 34 | assert.NotNil(t, err.Unwrap()) 35 | } 36 | 37 | func TestIssue127ErrorResponse(t *testing.T) { 38 | // Start a local HTTP server 39 | errMsg := "This is an html error body" 40 | server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 41 | // Test request parameters 42 | assert.Equal(t, "/time", req.URL.String()) 43 | // Send response to be tested 44 | rw.Header().Set("Content-Type", "text/html") 45 | rw.WriteHeader(400) 46 | rw.Write([]byte(fmt.Sprintf("%s", errMsg))) 47 | })) 48 | // Close the server when test finishes 49 | defer server.Close() 50 | 51 | endpointURL, err := url.Parse(server.URL) 52 | assert.Nil(t, err) 53 | opts := []ably.ClientOption{ 54 | ably.WithKey("xxxxxxx.yyyyyyy:zzzzzzz"), 55 | ably.WithTLS(false), 56 | ably.WithUseTokenAuth(true), 57 | ably.WithEndpoint(endpointURL.Hostname()), 58 | } 59 | port, _ := strconv.ParseInt(endpointURL.Port(), 10, 0) 60 | opts = append(opts, ably.WithPort(int(port))) 61 | client, err := ably.NewREST(opts...) 62 | assert.NoError(t, err) 63 | 64 | _, err = client.Time(context.Background()) 65 | assert.Error(t, err) 66 | assert.Contains(t, err.Error(), errMsg) 67 | } 68 | 69 | func TestErrorInfo(t *testing.T) { 70 | t.Run("without an error code", func(t *testing.T) { 71 | 72 | e := &ably.ErrorInfo{ 73 | StatusCode: 401, 74 | } 75 | assert.NotContains(t, e.Error(), "help.ably.io", 76 | "expected error message not to contain \"help.ably.io\"") 77 | }) 78 | t.Run("with an error code", func(t *testing.T) { 79 | 80 | e := &ably.ErrorInfo{ 81 | Code: 44444, 82 | } 83 | assert.Contains(t, e.Error(), "https://help.ably.io/error/44444", 84 | "expected error message %s to contain \"https://help.ably.io/error/44444\"", e.Error()) 85 | }) 86 | t.Run("with an error code and an href attribute", func(t *testing.T) { 87 | 88 | e := &ably.ErrorInfo{ 89 | Code: 44444, 90 | HRef: "http://foo.bar.com/", 91 | } 92 | assert.NotContains(t, e.Error(), "https://help.ably.io/error/44444", 93 | "expected error message %s not to contain \"https://help.ably.io/error/44444\"", e.Error()) 94 | assert.Contains(t, e.Error(), "http://foo.bar.com/", 95 | "expected error message %s to contain \"http://foo.bar.com/\"", e.Error()) 96 | }) 97 | 98 | t.Run("with an error code and a message with the same error URL", func(t *testing.T) { 99 | 100 | e := ably.NewErrorInfo(44444, errors.New("error https://help.ably.io/error/44444")) 101 | assert.Contains(t, e.Error(), "https://help.ably.io/error/44444", 102 | "expected error message %s to contain \"https://help.ably.io/error/44444\"", e.Error()) 103 | 104 | count := strings.Count(e.Error(), "https://help.ably.io/error/44444") 105 | assert.Equal(t, 1, count, "expected 1 occurrence of \"https://help.ably.io/error/44444\" got %d", count) 106 | 107 | }) 108 | t.Run("with an error code and a message with a different error URL", func(t *testing.T) { 109 | 110 | e := ably.NewErrorInfo(44444, errors.New("error https://help.ably.io/error/123123")) 111 | assert.Contains(t, e.Error(), "https://help.ably.io/error/44444", 112 | "expected error message %s to contain \"https://help.ably.io/error/44444\"", e.Error()) 113 | count := strings.Count(e.Error(), "help.ably.io") 114 | assert.Equal(t, 2, count, 115 | "expected 2 occurrences of \"help.ably.io\" got %d", count) 116 | count = strings.Count(e.Error(), "/123123") 117 | assert.Equal(t, 1, count, 118 | "expected 1 occurence of \"/123123\" got %d", count) 119 | count = strings.Count(e.Error(), "/44444") 120 | assert.Equal(t, 1, count, "expected 1 occurence of \"/44444\" got %d", count) 121 | }) 122 | } 123 | 124 | func TestIssue_154(t *testing.T) { 125 | server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 126 | rw.Header().Set("Content-Type", "text/html") 127 | rw.WriteHeader(http.StatusMethodNotAllowed) 128 | })) 129 | defer server.Close() 130 | 131 | endpointURL, err := url.Parse(server.URL) 132 | assert.NoError(t, err) 133 | opts := []ably.ClientOption{ 134 | ably.WithKey("xxxxxxx.yyyyyyy:zzzzzzz"), 135 | ably.WithTLS(false), 136 | ably.WithUseTokenAuth(true), 137 | ably.WithEndpoint(endpointURL.Hostname()), 138 | } 139 | port, _ := strconv.ParseInt(endpointURL.Port(), 10, 0) 140 | opts = append(opts, ably.WithPort(int(port))) 141 | client, e := ably.NewREST(opts...) 142 | assert.NoError(t, e) 143 | 144 | _, err = client.Time(context.Background()) 145 | assert.Error(t, err) 146 | et := err.(*ably.ErrorInfo) 147 | assert.Equal(t, http.StatusMethodNotAllowed, et.StatusCode, 148 | "expected %d got %d: %v", http.StatusMethodNotAllowed, et.StatusCode, err) 149 | } 150 | 151 | func Test_DNSOrTimeoutErr(t *testing.T) { 152 | dnsErr := net.DNSError{ 153 | Err: "Can't resolve host", 154 | Name: "Host unresolvable", 155 | Server: "rest.ably.com", 156 | IsTimeout: false, 157 | IsTemporary: false, 158 | IsNotFound: false, 159 | } 160 | 161 | WrappedDNSErr := fmt.Errorf("custom error occured %w", &dnsErr) 162 | if !ably.IsTimeoutOrDnsErr(WrappedDNSErr) { 163 | t.Fatalf("%v is a DNS error", WrappedDNSErr) 164 | } 165 | 166 | urlErr := url.Error{ 167 | URL: "rest.ably.io", 168 | Err: errors.New("URL error occured"), 169 | Op: "IO read OP", 170 | } 171 | 172 | if ably.IsTimeoutOrDnsErr(&urlErr) { 173 | t.Fatalf("%v is not a DNS or timeout error", urlErr) 174 | } 175 | 176 | urlErr.Err = &dnsErr 177 | 178 | if !ably.IsTimeoutOrDnsErr(WrappedDNSErr) { 179 | t.Fatalf("%v is a DNS error", urlErr) 180 | } 181 | 182 | dnsErr.IsTimeout = true 183 | 184 | if !ably.IsTimeoutOrDnsErr(WrappedDNSErr) { 185 | t.Fatalf("%v is a timeout error", urlErr) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /ably/event_emitter_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build !unit 2 | // +build !unit 3 | 4 | package ably_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/ably/ably-go/ablytest" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestEventEmitterConcurrency(t *testing.T) { 16 | em := ably.NewEventEmitter(ably.NewInternalLogger(ablytest.DiscardLogger)) 17 | 18 | type called struct { 19 | i int 20 | goOn chan struct{} 21 | } 22 | calls := make(chan called) 23 | 24 | for i := 0; i < 2; i++ { 25 | i := i 26 | em.OnAll(func(ably.EmitterData) { 27 | c := called{i: i, goOn: make(chan struct{})} 28 | calls <- c 29 | <-c.goOn 30 | }) 31 | } 32 | 33 | // Emit, and, since handlers are concurrent, expect a call per handler to 34 | // be initiated. 35 | 36 | em.Emit(ably.EmitterString("foo"), nil) 37 | 38 | var ongoingCalls []called 39 | 40 | for i := 0; i < 2; i++ { 41 | var call called 42 | ablytest.Instantly.Recv(t, &call, calls, t.Fatalf) 43 | ongoingCalls = append(ongoingCalls, call) 44 | } 45 | ablytest.Instantly.NoRecv(t, nil, calls, t.Fatalf) 46 | 47 | // While the last event is still being handled by each handler, a new event 48 | // should be enqueued. 49 | 50 | em.Emit(ably.EmitterString("foo"), nil) 51 | 52 | ablytest.Instantly.NoRecv(t, nil, calls, t.Fatalf) 53 | 54 | // Allow the first ongoing call to finish, which should then process the 55 | // enqueued event for that handler. 56 | 57 | close(ongoingCalls[0].goOn) 58 | var call called 59 | ablytest.Instantly.Recv(t, &call, calls, t.Fatalf) 60 | assert.Equal(t, ongoingCalls[0].i, call.i, 61 | "expected to unblock handler %d, got %d", ongoingCalls[0].i, call.i) 62 | 63 | close(call.goOn) 64 | 65 | // Unblock the other handler too. 66 | close(ongoingCalls[1].goOn) 67 | ablytest.Instantly.Recv(t, &call, calls, t.Fatalf) 68 | assert.Equal(t, ongoingCalls[1].i, call.i, 69 | "expected to unblock handler %d, got %d", ongoingCalls[1].i, call.i) 70 | close(call.goOn) 71 | 72 | // Make sure things still work after emptying the queues. 73 | 74 | em.Emit(ably.EmitterString("foo"), nil) 75 | for i := 0; i < 2; i++ { 76 | ablytest.Instantly.Recv(t, &call, calls, t.Fatalf) 77 | close(call.goOn) 78 | } 79 | 80 | ablytest.Instantly.NoRecv(t, nil, calls, t.Fatalf) 81 | } 82 | -------------------------------------------------------------------------------- /ably/generate.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | //go:generate go run ../scripts/errors/main.go -json ../common/protocol/errors.json -o errors.go 4 | -------------------------------------------------------------------------------- /ably/http_paginated_response_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build !unit 2 | // +build !unit 3 | 4 | package ably_test 5 | 6 | import ( 7 | "context" 8 | "net/http" 9 | "net/url" 10 | "sort" 11 | "testing" 12 | 13 | "github.com/ably/ably-go/ably" 14 | "github.com/ably/ably-go/ablytest" 15 | 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | func TestHTTPPaginatedFallback(t *testing.T) { 20 | app, err := ablytest.NewSandbox(nil) 21 | assert.NoError(t, err) 22 | defer app.Close() 23 | opts := app.Options(ably.WithUseBinaryProtocol(false), 24 | ably.WithEndpoint("ably.invalid"), 25 | ably.WithFallbackHosts(nil)) 26 | client, err := ably.NewREST(opts...) 27 | assert.NoError(t, err) 28 | t.Run("request_time", func(t *testing.T) { 29 | _, err := client.Request("get", "/time").Pages(context.Background()) 30 | assert.Error(t, err) 31 | }) 32 | } 33 | 34 | func TestHTTPPaginatedResponse(t *testing.T) { 35 | app, err := ablytest.NewSandbox(nil) 36 | assert.NoError(t, err) 37 | defer app.Close() 38 | client, err := ably.NewREST(app.Options()...) 39 | assert.NoError(t, err) 40 | t.Run("request_time", func(t *testing.T) { 41 | res, err := client.Request("get", "/time").Pages(context.Background()) 42 | assert.NoError(t, err) 43 | assert.Equal(t, http.StatusOK, res.StatusCode(), 44 | "expected %d got %d", http.StatusOK, res.StatusCode()) 45 | assert.True(t, res.Success(), "expected success to be true") 46 | res.Next(context.Background()) 47 | var items []interface{} 48 | err = res.Items(&items) 49 | assert.NoError(t, err) 50 | assert.Equal(t, 1, len(items), 51 | "expected 1 item got %d", len(items)) 52 | }) 53 | 54 | t.Run("request_404", func(t *testing.T) { 55 | res, err := client.Request("get", "/keys/ablyjs.test/requestToken").Pages(context.Background()) 56 | assert.NoError(t, err) 57 | assert.Equal(t, http.StatusNotFound, res.StatusCode(), 58 | "expected %d got %d", http.StatusNotFound, res.StatusCode()) 59 | assert.Equal(t, ably.ErrNotFound, res.ErrorCode(), 60 | "expected %d got %d", ably.ErrNotFound, res.ErrorCode()) 61 | assert.False(t, res.Success(), 62 | "expected success to be false") 63 | assert.NotEqual(t, "", res.ErrorMessage(), 64 | "expected error message") 65 | }) 66 | 67 | t.Run("request_post_get_messages", func(t *testing.T) { 68 | channelName := "http-paginated-result" 69 | channelPath := "/channels/" + channelName + "/messages" 70 | msgs := []ably.Message{ 71 | {Name: "faye", Data: "whittaker"}, 72 | {Name: "martin", Data: "reed"}, 73 | } 74 | 75 | t.Run("post", func(t *testing.T) { 76 | for _, message := range msgs { 77 | res, err := client.Request("POST", channelPath, ably.RequestWithBody(message)).Pages(context.Background()) 78 | assert.NoError(t, err) 79 | assert.Equal(t, http.StatusCreated, res.StatusCode(), 80 | "expected %d got %d", http.StatusCreated, res.StatusCode()) 81 | assert.True(t, res.Success(), 82 | "expected success to be true") 83 | res.Next(context.Background()) 84 | var items []interface{} 85 | err = res.Items(&items) 86 | assert.NoError(t, err) 87 | assert.Equal(t, 1, len(items), "expected 1 item got %d", len(items)) 88 | } 89 | }) 90 | 91 | t.Run("get", func(t *testing.T) { 92 | res, err := client.Request("get", channelPath, ably.RequestWithParams(url.Values{ 93 | "limit": {"1"}, 94 | "direction": {"forwards"}, 95 | })).Pages(context.Background()) 96 | assert.NoError(t, err) 97 | assert.Equal(t, http.StatusOK, res.StatusCode(), 98 | "expected %d got %d", http.StatusOK, res.StatusCode()) 99 | res.Next(context.Background()) 100 | var items []map[string]interface{} 101 | err = res.Items(&items) 102 | assert.NoError(t, err) 103 | assert.Equal(t, 1, len(items), "expected 1 item got %d", len(items)) 104 | item := items[0] 105 | name := item["name"] 106 | data := item["data"] 107 | assert.Equal(t, "faye", name, "expected name \"faye\" got %s", name) 108 | assert.Equal(t, "whittaker", data, "expected data \"whittaker\" got %s", data) 109 | 110 | if !res.Next(context.Background()) { 111 | t.Fatal(res.Err()) 112 | } 113 | err = res.Items(&items) 114 | assert.NoError(t, err) 115 | assert.Equal(t, 1, len(items), 116 | "expected 1 item got %d", len(items)) 117 | item = items[0] 118 | name = item["name"] 119 | data = item["data"] 120 | assert.Equal(t, "martin", name, 121 | "expected name \"martin\" got %s", name) 122 | assert.Equal(t, "reed", data, 123 | "expected data \"reed\" got %s", data) 124 | }) 125 | 126 | var msg []*ably.Message 127 | err := ablytest.AllPages(&msg, client.Channels.Get(channelName).History()) 128 | assert.NoError(t, err) 129 | sort.Slice(msg, func(i, j int) bool { 130 | return msg[i].Name < msg[j].Name 131 | }) 132 | assert.Equal(t, 2, len(msg), 133 | "expected 2 items in message got %d", len(msg)) 134 | assert.Equal(t, "faye", msg[0].Name, 135 | "expected name \"faye\" got %s", msg[0].Name) 136 | assert.Equal(t, "whittaker", msg[0].Data, 137 | "expected data \"whittaker\" got %s", msg[0].Data) 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/idempotent.go: -------------------------------------------------------------------------------- 1 | package ablyutil 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | ) 7 | 8 | // BaseID returns a base64 encoded 9 random bytes to be used in idempotent rest 9 | // publishing as part of message id. 10 | // 11 | // Spec RSL1k1 12 | func BaseID() (string, error) { 13 | r := make([]byte, 9) 14 | _, err := rand.Read(r) 15 | if err != nil { 16 | return "", err 17 | } 18 | return base64.StdEncoding.EncodeToString(r), nil 19 | } 20 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/merge.go: -------------------------------------------------------------------------------- 1 | package ablyutil 2 | 3 | import "reflect" 4 | 5 | // Merge iterates over fields of struct pointed by v and when it's non-zero, 6 | // copies its value to corresponding filed in orig. 7 | // 8 | // merge assumes both orig and v are pointers to a struct value of the 9 | // same type. 10 | // 11 | // When defaults is true, merge uses v as the source of default values for each 12 | // field; the default is copied when orig's field is a zero-value. 13 | func Merge(orig, v interface{}, defaults bool) { 14 | vv := reflect.ValueOf(v).Elem() 15 | if !vv.IsValid() { 16 | return 17 | } 18 | vorig := reflect.ValueOf(orig).Elem() 19 | for i := 0; i < vorig.NumField(); i++ { 20 | field := vv.Field(i) 21 | if defaults { 22 | field = vorig.Field(i) 23 | } 24 | var empty bool 25 | switch field.Type().Kind() { 26 | case reflect.Struct: 27 | empty = true // TODO: merge structs recursively 28 | case reflect.Chan, reflect.Func, reflect.Slice, reflect.Map: 29 | empty = field.IsNil() 30 | default: 31 | empty = field.Interface() == reflect.Zero(field.Type()).Interface() 32 | } 33 | if !empty { 34 | vorig.Field(i).Set(vv.Field(i)) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/msgpack.go: -------------------------------------------------------------------------------- 1 | package ablyutil 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "reflect" 7 | 8 | "github.com/ugorji/go/codec" 9 | ) 10 | 11 | var handle codec.MsgpackHandle 12 | 13 | func init() { 14 | handle.Raw = true 15 | handle.WriteExt = true 16 | handle.RawToString = true 17 | handle.MapType = reflect.TypeOf(map[string]interface{}(nil)) 18 | } 19 | 20 | // UnmarshalMsgpack decodes the MessagePack-encoded data and stores the result in the 21 | // value pointed to by v. 22 | func UnmarshalMsgpack(data []byte, v interface{}) error { 23 | return decodeMsg(bytes.NewReader(data), v) 24 | } 25 | 26 | // decodeMsg decodes msgpack message read from r into v. 27 | func decodeMsg(r io.Reader, v interface{}) error { 28 | dec := codec.NewDecoder(r, &handle) 29 | return dec.Decode(v) 30 | } 31 | 32 | // MarshalMsgpack returns msgpack encoding of v 33 | func MarshalMsgpack(v interface{}) ([]byte, error) { 34 | var buf bytes.Buffer 35 | err := encodeMsg(&buf, v) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return buf.Bytes(), nil 40 | } 41 | 42 | // encodeMsg encodes v into msgpack format and writes the output to w. 43 | func encodeMsg(w io.Writer, v interface{}) error { 44 | enc := codec.NewEncoder(w, &handle) 45 | return enc.Encode(v) 46 | } 47 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/msgpack_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ablyutil 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "testing" 10 | ) 11 | 12 | func TestMsgpack(t *testing.T) { 13 | type Object1 struct { 14 | Key int64 `codec:"my_key"` 15 | } 16 | type Object2 struct { 17 | Key float64 `codec:"my_key"` 18 | } 19 | t.Run("must decode int64 into float64", func(ts *testing.T) { 20 | var buf bytes.Buffer 21 | err := encodeMsg(&buf, &Object1{Key: 12}) 22 | if err != nil { 23 | ts.Fatal(err) 24 | } 25 | b := &Object2{} 26 | err = decodeMsg(&buf, &b) 27 | if err != nil { 28 | ts.Fatal(err) 29 | } 30 | if b.Key != 12 { 31 | ts.Errorf("expected 12 got %v", b.Key) 32 | } 33 | }) 34 | } 35 | 36 | func TestMsgpackJson(t *testing.T) { 37 | extras := map[string]interface{}{ 38 | "headers": map[string]interface{}{ 39 | "version": 1, 40 | }, 41 | } 42 | 43 | b, err := MarshalMsgpack(extras) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | var got map[string]interface{} 49 | err = UnmarshalMsgpack(b, &got) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | buff := bytes.Buffer{} 55 | err = json.NewEncoder(&buff).Encode(got) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/regex.go: -------------------------------------------------------------------------------- 1 | package ablyutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | // derivedChannelMatch provides the qualifyingParam and channelName from a channel regex match for derived channels 10 | type derivedChannelMatch struct { 11 | QualifierParam string 12 | ChannelName string 13 | } 14 | 15 | // This regex check is to retain existing channel params if any e.g [?rewind=1]foo to 16 | // [filter=xyz?rewind=1]foo. This is to keep channel compatibility around use of 17 | // channel params that work with derived channels. 18 | func MatchDerivedChannel(name string) (*derivedChannelMatch, error) { 19 | regex := `^(\[([^?]*)(?:(.*))\])?(.+)$` 20 | r, err := regexp.Compile(regex) 21 | if err != nil { 22 | err := errors.New("regex compilation failed") 23 | return nil, err 24 | } 25 | match := r.FindStringSubmatch(name) 26 | 27 | if len(match) == 0 || len(match) < 5 { 28 | err := errors.New("regex match failed") 29 | return nil, err 30 | } 31 | // Fail if there is already a channel qualifier, 32 | // eg [meta]foo should fail instead of just overriding with [filter=xyz]foo 33 | if len(match[2]) > 0 { 34 | err := fmt.Errorf("cannot use a derived option with a %s channel", match[2]) 35 | return nil, err 36 | } 37 | 38 | // Return match values to be added to derive channel quantifier. 39 | return &derivedChannelMatch{ 40 | QualifierParam: match[3], 41 | ChannelName: match[4], 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/regex_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ablyutil 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMatchDerivedChannel(t *testing.T) { 13 | var channels = []struct { 14 | name string 15 | input string 16 | want *derivedChannelMatch 17 | }{ 18 | {"valid with base channel name", "foo", &derivedChannelMatch{QualifierParam: "", ChannelName: "foo"}}, 19 | {"valid with base channel namespace", "foo:bar", &derivedChannelMatch{QualifierParam: "", ChannelName: "foo:bar"}}, 20 | {"valid with existing qualifying option", "[?rewind=1]foo", &derivedChannelMatch{QualifierParam: "?rewind=1", ChannelName: "foo"}}, 21 | {"valid with existing qualifying option with channel namespace", "[?rewind=1]foo:bar", &derivedChannelMatch{QualifierParam: "?rewind=1", ChannelName: "foo:bar"}}, 22 | {"fail with invalid param with channel namespace", "[param:invalid]foo:bar", nil}, 23 | {"fail with wrong channel option param", "[param=1]foo", nil}, 24 | {"fail with invalid qualifying option", "[meta]foo", nil}, 25 | {"fail with invalid regex match", "[failed-match]foo", nil}, 26 | } 27 | 28 | for _, tt := range channels { 29 | t.Run(tt.name, func(t *testing.T) { 30 | match, err := MatchDerivedChannel(tt.input) 31 | if err != nil { 32 | assert.Error(t, err, "An error is expected for the regex match") 33 | } 34 | assert.Equal(t, tt.want, match, "invalid output received") 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/strings.go: -------------------------------------------------------------------------------- 1 | package ablyutil 2 | 3 | import ( 4 | "math/rand" 5 | "sort" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 11 | 12 | var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) 13 | 14 | func GenerateRandomString(length int) string { 15 | b := make([]byte, length) 16 | for i := range b { 17 | b[i] = charset[seededRand.Intn(len(charset))] 18 | } 19 | return string(b) 20 | } 21 | 22 | type HashSet map[string]struct{} // struct {} has zero space complexity 23 | 24 | func NewHashSet() HashSet { 25 | return make(HashSet) 26 | } 27 | 28 | func (s HashSet) Add(item string) { 29 | s[item] = struct{}{} 30 | } 31 | 32 | func (s HashSet) Remove(item string) { 33 | delete(s, item) 34 | } 35 | 36 | func (s HashSet) Has(item string) bool { 37 | _, ok := s[item] 38 | return ok 39 | } 40 | 41 | func Copy(list []string) []string { 42 | copiedList := make([]string, len(list)) 43 | copy(copiedList, list) 44 | return copiedList 45 | } 46 | 47 | func Sort(list []string) []string { 48 | copiedList := Copy(list) 49 | sort.Strings(copiedList) 50 | return copiedList 51 | } 52 | 53 | func Shuffle(list []string) []string { 54 | copiedList := Copy(list) 55 | if len(copiedList) <= 1 { 56 | return copiedList 57 | } 58 | rand.Seed(time.Now().UnixNano()) 59 | rand.Shuffle(len(copiedList), func(i, j int) { copiedList[i], copiedList[j] = copiedList[j], copiedList[i] }) 60 | return copiedList 61 | } 62 | 63 | func SliceContains(s []string, str string) bool { 64 | for _, v := range s { 65 | if v == str { 66 | return true 67 | } 68 | } 69 | return false 70 | } 71 | 72 | func Empty(s string) bool { 73 | return len(strings.TrimSpace(s)) == 0 74 | } 75 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/strings_test.go: -------------------------------------------------------------------------------- 1 | package ablyutil_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ably/ably-go/ably/internal/ablyutil" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_string(t *testing.T) { 11 | t.Run("String array Shuffle", func(t *testing.T) { 12 | t.Parallel() 13 | 14 | strList := []string{} 15 | shuffledList := ablyutil.Shuffle(strList) 16 | assert.Equal(t, strList, shuffledList) 17 | 18 | strList = []string{"a"} 19 | shuffledList = ablyutil.Shuffle(strList) 20 | assert.Equal(t, strList, shuffledList) 21 | 22 | strList = []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p"} 23 | shuffledList = ablyutil.Shuffle(strList) 24 | assert.NotEqual(t, strList, shuffledList) 25 | assert.Equal(t, ablyutil.Sort(strList), ablyutil.Sort(shuffledList)) 26 | }) 27 | 28 | t.Run("String array contains", func(t *testing.T) { 29 | t.Parallel() 30 | strarr := []string{"apple", "banana", "dragonfruit"} 31 | 32 | if !ablyutil.SliceContains(strarr, "apple") { 33 | t.Error("String array should contain apple") 34 | } 35 | if ablyutil.SliceContains(strarr, "orange") { 36 | t.Error("String array should not contain orange") 37 | } 38 | }) 39 | 40 | t.Run("Empty String", func(t *testing.T) { 41 | t.Parallel() 42 | str := "" 43 | if !ablyutil.Empty(str) { 44 | t.Error("String should be empty") 45 | } 46 | str = " " 47 | if !ablyutil.Empty(str) { 48 | t.Error("String should be empty") 49 | } 50 | str = "ab" 51 | if ablyutil.Empty(str) { 52 | t.Error("String should not be empty") 53 | } 54 | }) 55 | } 56 | 57 | func TestHashSet(t *testing.T) { 58 | t.Run("Add should not duplicate entries", func(t *testing.T) { 59 | hashSet := ablyutil.NewHashSet() 60 | hashSet.Add("apple") 61 | hashSet.Add("apple") 62 | assert.Len(t, hashSet, 1) 63 | 64 | hashSet.Add("banana") 65 | assert.Len(t, hashSet, 2) 66 | 67 | hashSet.Add("orange") 68 | assert.Len(t, hashSet, 3) 69 | 70 | hashSet.Add("banana") 71 | hashSet.Add("apple") 72 | hashSet.Add("orange") 73 | hashSet.Add("orange") 74 | 75 | assert.Len(t, hashSet, 3) 76 | }) 77 | 78 | t.Run("Should check if item is present", func(t *testing.T) { 79 | hashSet := ablyutil.NewHashSet() 80 | hashSet.Add("apple") 81 | hashSet.Add("orange") 82 | if !hashSet.Has("apple") { 83 | t.Fatalf("Set should contain apple") 84 | } 85 | if hashSet.Has("banana") { 86 | t.Fatalf("Set shouldm't contain banana") 87 | } 88 | if !hashSet.Has("orange") { 89 | t.Fatalf("Set should contain orange") 90 | } 91 | }) 92 | 93 | t.Run("Should remove element", func(t *testing.T) { 94 | hashSet := ablyutil.NewHashSet() 95 | hashSet.Add("apple") 96 | assert.Len(t, hashSet, 1) 97 | 98 | hashSet.Add("orange") 99 | assert.Len(t, hashSet, 2) 100 | 101 | hashSet.Remove("apple") 102 | assert.Len(t, hashSet, 1) 103 | 104 | if hashSet.Has("apple") { 105 | t.Fatalf("Set shouldm't contain apple") 106 | } 107 | hashSet.Remove("orange") 108 | assert.Len(t, hashSet, 0) 109 | 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /ably/internal/ablyutil/time.go: -------------------------------------------------------------------------------- 1 | package ablyutil 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // After returns a channel that is sent the current time once the given 9 | // duration has passed. If the context is cancelled before that, the channel 10 | // is immediately closed. 11 | func After(ctx context.Context, d time.Duration) <-chan time.Time { 12 | timer := time.NewTimer(d) 13 | 14 | ch := make(chan time.Time, 1) 15 | 16 | go func() { 17 | defer timer.Stop() 18 | select { 19 | case <-ctx.Done(): 20 | close(ch) 21 | case t := <-timer.C: 22 | ch <- t 23 | } 24 | }() 25 | 26 | return ch 27 | } 28 | 29 | type TimerFunc func(context.Context, time.Duration) <-chan time.Time 30 | 31 | // NewTicker repeatedly calls the given TimerFunc, which should behave like 32 | // After, until the context it cancelled. It returns a channel to which it sends 33 | // every value produced by the TimerFunc. 34 | func NewTicker(after TimerFunc) TimerFunc { 35 | return func(ctx context.Context, d time.Duration) <-chan time.Time { 36 | ch := make(chan time.Time, 1) 37 | 38 | go func() { 39 | for { 40 | t, ok := <-after(ctx, d) 41 | if !ok { 42 | close(ch) 43 | return 44 | } 45 | ch <- t 46 | } 47 | }() 48 | 49 | return ch 50 | } 51 | } 52 | 53 | // ContextWithTimeout is like context.WithTimeout, but using the provided 54 | // TimerFunc for setting the timer. 55 | func ContextWithTimeout(ctx context.Context, after TimerFunc, timeout time.Duration) (context.Context, context.CancelFunc) { 56 | ctx, cancel := context.WithCancel(ctx) 57 | 58 | // If the timer expires, we cancel the context. But then we need the context's 59 | // error to be context.DeadlineExceeded instead of context.Canceled. 60 | // uses same context as above, doesn't create new context 61 | ctx, setErr := contextWithCustomError(ctx) 62 | 63 | go func() { 64 | _, timerFired := <-after(ctx, timeout) 65 | if timerFired { 66 | setErr(context.DeadlineExceeded) 67 | } 68 | cancel() 69 | }() 70 | return ctx, cancel 71 | } 72 | 73 | type contextWithCustomErr struct { 74 | context.Context 75 | err error 76 | } 77 | 78 | func contextWithCustomError(ctx context.Context) (_ context.Context, setError func(error)) { 79 | customContext := contextWithCustomErr{ctx, nil} 80 | return &customContext, func(err error) { 81 | customContext.err = err 82 | } 83 | } 84 | 85 | func (ctx *contextWithCustomErr) Err() error { 86 | if ctx.err != nil { 87 | return ctx.err 88 | } 89 | return ctx.Context.Err() 90 | } 91 | -------------------------------------------------------------------------------- /ably/logger.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | type LogLevel uint 9 | 10 | const ( 11 | LogNone LogLevel = iota 12 | LogError 13 | LogWarning 14 | LogInfo 15 | LogVerbose 16 | LogDebug 17 | ) 18 | 19 | var logLevelNames = map[LogLevel]string{ 20 | LogError: "ERROR", 21 | LogWarning: "WARN", 22 | LogInfo: "INFO", 23 | LogVerbose: "VERBOSE", 24 | LogDebug: "DEBUG", 25 | } 26 | 27 | func (l LogLevel) String() string { 28 | return logLevelNames[l] 29 | } 30 | 31 | type filteredLogger struct { 32 | Logger Logger 33 | Level LogLevel 34 | } 35 | 36 | func (l filteredLogger) Is(level LogLevel) bool { 37 | return l.Level != LogNone && l.Level >= level 38 | } 39 | 40 | func (l filteredLogger) Printf(level LogLevel, format string, v ...interface{}) { 41 | if l.Is(level) { 42 | l.Logger.Printf(level, format, v...) 43 | } 44 | } 45 | 46 | // Logger is an interface for ably loggers. 47 | type Logger interface { 48 | Printf(level LogLevel, format string, v ...interface{}) 49 | } 50 | 51 | // stdLogger wraps log.Logger to satisfy the Logger interface. 52 | type stdLogger struct { 53 | *log.Logger 54 | } 55 | 56 | func (s *stdLogger) Printf(level LogLevel, format string, v ...interface{}) { 57 | s.Logger.Printf(fmt.Sprintf("[%s] %s", level, format), v...) 58 | } 59 | 60 | // logger is the internal logger type, with helper methods that wrap the raw Logger interface. 61 | type logger struct { 62 | l Logger 63 | } 64 | 65 | func (l logger) Error(v ...interface{}) { 66 | l.print(LogError, v...) 67 | } 68 | 69 | func (l logger) Errorf(fmt string, v ...interface{}) { 70 | l.l.Printf(LogError, fmt, v...) 71 | } 72 | 73 | func (l logger) Warn(v ...interface{}) { 74 | l.print(LogWarning, v...) 75 | } 76 | 77 | func (l logger) Warnf(fmt string, v ...interface{}) { 78 | l.l.Printf(LogWarning, fmt, v...) 79 | } 80 | 81 | func (l logger) Info(v ...interface{}) { 82 | l.print(LogInfo, v...) 83 | } 84 | 85 | func (l logger) Infof(fmt string, v ...interface{}) { 86 | l.l.Printf(LogInfo, fmt, v...) 87 | } 88 | 89 | func (l logger) Verbose(v ...interface{}) { 90 | l.print(LogVerbose, v...) 91 | } 92 | 93 | func (l logger) Verbosef(fmt string, v ...interface{}) { 94 | l.l.Printf(LogVerbose, fmt, v...) 95 | } 96 | 97 | func (l logger) Debugf(fmt string, v ...interface{}) { 98 | l.l.Printf(LogDebug, fmt, v...) 99 | } 100 | 101 | func (l logger) Debug(v ...interface{}) { 102 | l.print(LogDebug, v...) 103 | } 104 | 105 | func (l logger) print(level LogLevel, v ...interface{}) { 106 | l.l.Printf(level, fmt.Sprint(v...)) 107 | } 108 | -------------------------------------------------------------------------------- /ably/logger_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ably/ably-go/ably" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type dummyLogger struct { 15 | printf int 16 | } 17 | 18 | func (d *dummyLogger) Printf(level ably.LogLevel, format string, v ...interface{}) { 19 | d.printf++ 20 | } 21 | 22 | func TestFilteredLogger(t *testing.T) { 23 | t.Run("must log smaller or same level", func(t *testing.T) { 24 | 25 | l := &dummyLogger{} 26 | lg := &ably.FilteredLogger{ 27 | Level: ably.LogDebug, 28 | Logger: l, 29 | } 30 | say := "log this" 31 | lg.Printf(ably.LogVerbose, say) 32 | lg.Printf(ably.LogInfo, say) 33 | lg.Printf(ably.LogWarning, say) 34 | lg.Printf(ably.LogError, say) 35 | assert.Equal(t, 4, l.printf, 36 | "expected 4 log messages got %d", l.printf) 37 | }) 38 | t.Run("must log nothing for LogNone", func(t *testing.T) { 39 | 40 | l := &dummyLogger{} 41 | lg := &ably.FilteredLogger{ 42 | Logger: l, 43 | } 44 | say := "log this" 45 | lg.Printf(ably.LogVerbose, say) 46 | lg.Printf(ably.LogInfo, say) 47 | lg.Printf(ably.LogWarning, say) 48 | lg.Printf(ably.LogError, say) 49 | assert.Equal(t, 0, l.printf, 50 | "expected nothing to be logged") 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /ably/os_version_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !darwin && !windows 2 | // +build !linux,!darwin,!windows 3 | 4 | package ably 5 | 6 | func goOSIdentifier() string { 7 | return "" 8 | } 9 | -------------------------------------------------------------------------------- /ably/os_version_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package ably 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "runtime" 10 | 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | func goOSIdentifier() string { 15 | utsname := unix.Utsname{} 16 | err := unix.Uname(&utsname) 17 | if err != nil { 18 | return "" 19 | } 20 | release := bytes.Trim(utsname.Release[:], "\x00") 21 | return fmt.Sprintf("%s/%s", runtime.GOOS, release) 22 | } 23 | -------------------------------------------------------------------------------- /ably/os_version_windows.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "golang.org/x/sys/windows" 8 | ) 9 | 10 | func goOSIdentifier() string { 11 | v := windows.RtlGetVersion() 12 | return fmt.Sprintf("%s/%d.%d.%d", runtime.GOOS, v.MajorVersion, v.MinorVersion, v.BuildNumber) 13 | } 14 | -------------------------------------------------------------------------------- /ably/paginated_result.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "path" 8 | "reflect" 9 | "regexp" 10 | ) 11 | 12 | type Direction string 13 | 14 | const ( 15 | Backwards Direction = "backwards" 16 | Forwards Direction = "forwards" 17 | ) 18 | 19 | type paginatedRequest struct { 20 | path string 21 | rawPath string 22 | params url.Values 23 | 24 | query queryFunc 25 | } 26 | 27 | func (r *REST) newPaginatedRequest(path, rawPath string, params url.Values) paginatedRequest { 28 | return paginatedRequest{ 29 | path: path, 30 | rawPath: rawPath, 31 | params: params, 32 | 33 | query: query(r.get), 34 | } 35 | } 36 | 37 | // PaginatedResult is a generic iterator for PaginatedResult pagination. 38 | // Items decoding is delegated to type-specific wrappers. 39 | // 40 | // See "Paginated results" section in the package-level documentation. 41 | type PaginatedResult struct { 42 | basePath string 43 | nextLink string 44 | firstLink string 45 | res *http.Response 46 | err error 47 | 48 | query queryFunc 49 | first bool 50 | } 51 | 52 | // load - It loads first page of results. Must be called from the type-specific 53 | // wrapper Pages method that creates the PaginatedResult object. 54 | func (p *PaginatedResult) load(ctx context.Context, r paginatedRequest) error { 55 | p.basePath = path.Dir(r.path) 56 | p.firstLink = (&url.URL{ 57 | Path: r.path, 58 | RawPath: r.rawPath, 59 | RawQuery: r.params.Encode(), 60 | }).String() 61 | p.query = r.query 62 | return p.First(ctx) 63 | } 64 | 65 | // loadItems loads the first page of results and returns a next function. Must 66 | // be called from the type-specific wrapper Items method that creates the 67 | // PaginatedItems object. 68 | // 69 | // The returned next function must be called from the wrapper's Next method, and 70 | // returns the index of the object that should be returned by the Item method, 71 | // previously loading the next page if necessary. 72 | // 73 | // pageDecoder will be called each time a new page is retrieved under the hood. 74 | // It should return a destination object on which the page of results will be 75 | // decoded, and a pageLength function that, when called after the page has been 76 | // decoded, must return the length of the page. 77 | func (p *PaginatedResult) loadItems( 78 | ctx context.Context, 79 | r paginatedRequest, 80 | pageDecoder func() (page interface{}, pageLength func() int), 81 | ) ( 82 | next func(context.Context) (int, bool), 83 | err error, 84 | ) { 85 | err = p.load(ctx, r) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | var page interface{} 91 | var pageLen int 92 | nextItem := 0 93 | 94 | return func(ctx context.Context) (int, bool) { 95 | if nextItem == 0 { 96 | var getLen func() int 97 | page, getLen = pageDecoder() 98 | hasNext := p.next(ctx, page) 99 | if !hasNext { 100 | return 0, false 101 | } 102 | pageLen = getLen() 103 | if pageLen == 0 { // compatible with hasNext if first page is empty 104 | return 0, false 105 | } 106 | } 107 | 108 | idx := nextItem 109 | nextItem = (nextItem + 1) % pageLen 110 | return idx, true 111 | }, nil 112 | } 113 | 114 | func (p *PaginatedResult) goTo(ctx context.Context, link string) error { 115 | var err error 116 | p.res, err = p.query(ctx, link) 117 | if err != nil { 118 | return err 119 | } 120 | p.nextLink = "" 121 | for _, rawLink := range p.res.Header["Link"] { 122 | m := relLinkRegexp.FindStringSubmatch(rawLink) 123 | if len(m) == 0 { 124 | continue 125 | } 126 | relPath, rel := m[1], m[2] 127 | linkPath := path.Join(p.basePath, relPath) 128 | switch rel { 129 | case "first": 130 | p.firstLink = linkPath 131 | case "next": 132 | p.nextLink = linkPath 133 | } 134 | } 135 | return nil 136 | } 137 | 138 | // next loads the next page of items, if there is one. It returns whether a page 139 | // was successfully loaded or not; after it returns false, Err should be 140 | // called to check for any errors. 141 | // 142 | // Items can then be inspected with the type-specific Items method. 143 | // 144 | // For items iterators, use the next function returned by loadItems instead. 145 | func (p *PaginatedResult) next(ctx context.Context, into interface{}) bool { 146 | if !p.first { 147 | if p.nextLink == "" { 148 | return false 149 | } 150 | p.err = p.goTo(ctx, p.nextLink) 151 | if p.err != nil { 152 | return false 153 | } 154 | } 155 | p.first = false 156 | 157 | p.err = decodeResp(p.res, into) 158 | return p.err == nil 159 | } 160 | 161 | // First loads the first page of items. Next should be called before inspecting 162 | // the Items. 163 | func (p *PaginatedResult) First(ctx context.Context) error { 164 | p.first = true 165 | return p.goTo(ctx, p.firstLink) 166 | } 167 | 168 | // Err returns the error that caused Next to fail, if there was one. 169 | func (p *PaginatedResult) Err() error { 170 | return p.err 171 | } 172 | 173 | // relLinkRegexp is the regexp that matches our pagination format 174 | var relLinkRegexp = regexp.MustCompile(`<(?P[^>]+)>; rel="(?P[^"]+)"`) 175 | 176 | type errInvalidType struct { 177 | typ reflect.Type 178 | } 179 | 180 | func (err errInvalidType) Error() string { 181 | return "requested value of incompatible type: " + err.typ.String() 182 | } 183 | 184 | // queryFunc queries the given URL and gives non-nil HTTP response if no error 185 | // occurred. 186 | type queryFunc func(ctx context.Context, url string) (*http.Response, error) 187 | 188 | func copyHeader(dest, src http.Header) { 189 | for k, v := range src { 190 | d := make([]string, len(v)) 191 | copy(d, v) 192 | dest[k] = v 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /ably/proto_action.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | type protoAction int8 4 | 5 | const ( 6 | actionHeartbeat protoAction = iota 7 | actionAck 8 | actionNack 9 | actionConnect 10 | actionConnected 11 | actionDisconnect 12 | actionDisconnected 13 | actionClose 14 | actionClosed 15 | actionError 16 | actionAttach 17 | actionAttached 18 | actionDetach 19 | actionDetached 20 | actionPresence 21 | actionMessage 22 | actionSync 23 | actionAuth 24 | ) 25 | 26 | var actions = map[protoAction]string{ 27 | actionHeartbeat: "heartbeat", 28 | actionAck: "ack", 29 | actionNack: "nack", 30 | actionConnect: "connect", 31 | actionConnected: "connected", 32 | actionDisconnect: "disconnect", 33 | actionDisconnected: "disconnected", 34 | actionClose: "close", 35 | actionClosed: "closed", 36 | actionError: "error", 37 | actionAttach: "attach", 38 | actionAttached: "attached", 39 | actionDetach: "detach", 40 | actionDetached: "detached", 41 | actionPresence: "presence", 42 | actionMessage: "message", 43 | actionSync: "sync", 44 | actionAuth: "auth", 45 | } 46 | 47 | func (a protoAction) String() string { 48 | return actions[a] 49 | } 50 | -------------------------------------------------------------------------------- /ably/proto_channel_options.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | type channelParams map[string]string 4 | 5 | // ChannelMode Describes the possible flags used to configure client capabilities, using [ably.ChannelOption]. 6 | type ChannelMode int64 7 | 8 | const ( 9 | // ChannelModePresence allows the attached channel to enter Presence. 10 | ChannelModePresence ChannelMode = iota + 1 11 | // ChannelModePublish allows for messages to be published on the attached channel. 12 | ChannelModePublish 13 | // ChannelModeSubscribe allows the attached channel to subscribe to messages. 14 | ChannelModeSubscribe 15 | // ChannelModePresenceSubscribe allows the attached channel to subscribe to Presence updates. 16 | ChannelModePresenceSubscribe 17 | ) 18 | 19 | func (mode ChannelMode) toFlag() protoFlag { 20 | switch mode { 21 | case ChannelModePresence: 22 | return flagPresence 23 | case ChannelModePublish: 24 | return flagPublish 25 | case ChannelModeSubscribe: 26 | return flagSubscribe 27 | case ChannelModePresenceSubscribe: 28 | return flagPresenceSubscribe 29 | default: 30 | return 0 31 | } 32 | } 33 | 34 | func channelModeFromFlag(flags protoFlag) []ChannelMode { 35 | var modes []ChannelMode 36 | if flags.Has(flagPresence) { 37 | modes = append(modes, ChannelModePresence) 38 | } 39 | if flags.Has(flagPublish) { 40 | modes = append(modes, ChannelModePublish) 41 | } 42 | if flags.Has(flagSubscribe) { 43 | modes = append(modes, ChannelModeSubscribe) 44 | } 45 | if flags.Has(flagPresenceSubscribe) { 46 | modes = append(modes, ChannelModePresenceSubscribe) 47 | } 48 | return modes 49 | } 50 | 51 | // protoChannelOptions defines additional properties to a [ably.RESTChannel] or [ably.RealtimeChannel] object, 52 | // such as encryption, [ably.ChannelMode] and channel parameters. 53 | // It defines options provided for creating a new channel. 54 | type protoChannelOptions struct { 55 | Cipher CipherParams 56 | cipher channelCipher 57 | Params channelParams 58 | Modes []ChannelMode 59 | } 60 | -------------------------------------------------------------------------------- /ably/proto_channel_options_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/ably/ably-go/ably" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestRealtimeChannelModes_ToFlag(t *testing.T) { 16 | mode := ably.ChannelModePresence 17 | flag := ably.ChannelModeToFlag(mode) 18 | assert.Equal(t, ably.FlagPresence, flag, 19 | "Expected %v, received %v", ably.FlagPresence, flag) 20 | mode = ably.ChannelModeSubscribe 21 | flag = ably.ChannelModeToFlag(mode) 22 | assert.Equal(t, ably.FlagSubscribe, flag, 23 | "Expected %v, received %v", ably.FlagSubscribe, flag) 24 | mode = ably.ChannelModePublish 25 | flag = ably.ChannelModeToFlag(mode) 26 | assert.Equal(t, ably.FlagPublish, flag, 27 | "Expected %v, received %v", ably.FlagPublish, flag) 28 | mode = ably.ChannelModePresenceSubscribe 29 | flag = ably.ChannelModeToFlag(mode) 30 | assert.Equal(t, ably.FlagPresenceSubscribe, flag, 31 | "Expected %v, received %v", ably.FlagPresenceSubscribe, flag) 32 | } 33 | 34 | func TestRealtimeChannelModes_FromFlag(t *testing.T) { 35 | 36 | // checks if element is present in the array 37 | inArray := func(v interface{}, in interface{}) (ok bool) { 38 | i := 0 39 | val := reflect.Indirect(reflect.ValueOf(in)) 40 | switch val.Kind() { 41 | case reflect.Slice, reflect.Array: 42 | for ; i < val.Len(); i++ { 43 | if ok = v == val.Index(i).Interface(); ok { 44 | return 45 | } 46 | } 47 | } 48 | return 49 | } 50 | 51 | flags := ably.FlagPresence | ably.FlagPresenceSubscribe | ably.FlagSubscribe 52 | modes := ably.ChannelModeFromFlag(flags) 53 | 54 | assert.True(t, inArray(ably.ChannelModePresence, modes), 55 | "Expected %v to be present in %v", ably.ChannelModePresence, modes) 56 | assert.True(t, inArray(ably.ChannelModePresenceSubscribe, modes), 57 | "Expected %v to be present in %v", ably.ChannelModePresenceSubscribe, modes) 58 | assert.True(t, inArray(ably.ChannelModeSubscribe, modes), 59 | "Expected %v to be present in %v", ably.ChannelModeSubscribe, modes) 60 | assert.False(t, inArray(ably.ChannelModePublish, modes), 61 | "Expected %v not to be present in %v", ably.ChannelModePublish, modes) 62 | } 63 | -------------------------------------------------------------------------------- /ably/proto_channel_propeties.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | // CP2 4 | type ChannelProperties struct { 5 | // AttachSerial contains the channelSerial from latest ATTACHED ProtocolMessage received on the channel, see CP2a, RTL15a 6 | AttachSerial string 7 | 8 | // ChannelSerial contains the channelSerial from latest ProtocolMessage of action type Message/PresenceMessage received on the channel, see CP2b, RTL15b. 9 | ChannelSerial string 10 | } 11 | -------------------------------------------------------------------------------- /ably/proto_error.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | // errorInfo describes an error object returned via ProtocolMessage. 4 | type errorInfo struct { 5 | StatusCode int `json:"statusCode,omitempty" codec:"statusCode,omitempty"` 6 | Code int `json:"code,omitempty" codec:"code,omitempty"` 7 | HRef string `json:"href,omitempty" codec:"href,omitempty"` //spec TI4 8 | Message string `json:"message,omitempty" codec:"message,omitempty"` 9 | Server string `json:"serverId,omitempty" codec:"serverId,omitempty"` 10 | } 11 | 12 | func (e *errorInfo) FromMap(ctx map[string]interface{}) { 13 | if v, ok := ctx["statusCode"]; ok { 14 | e.StatusCode = coerceInt(v) 15 | } 16 | if v, ok := ctx["code"]; ok { 17 | e.Code = coerceInt(v) 18 | } 19 | if v, ok := ctx["href"]; ok { 20 | e.HRef = v.(string) 21 | } 22 | if v, ok := ctx["message"]; ok { 23 | e.Message = v.(string) 24 | } 25 | if v, ok := ctx["serverId"]; ok { 26 | e.Server = v.(string) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ably/proto_http.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | // constants for rsc7 10 | const ( 11 | ablyProtocolVersionHeader = "X-Ably-Version" 12 | ablyErrorCodeHeader = "X-Ably-Errorcode" 13 | ablyErrorMessageHeader = "X-Ably-Errormessage" 14 | clientLibraryVersion = "1.2.22" 15 | clientRuntimeName = "go" 16 | ablyProtocolVersion = "2" // CSV2 17 | ablyClientIDHeader = "X-Ably-ClientId" 18 | hostHeader = "Host" 19 | ablyAgentHeader = "Ably-Agent" // RSC7d 20 | ablySDKIdentifier = "ably-go/" + clientLibraryVersion // RSC7d1 21 | ) 22 | 23 | var goRuntimeIdentifier = func() string { 24 | return fmt.Sprintf("%s/%s", clientRuntimeName, runtime.Version()[2:]) 25 | }() 26 | 27 | func ablyAgentIdentifier(agents map[string]string) string { 28 | identifiers := []string{ 29 | ablySDKIdentifier, 30 | goRuntimeIdentifier, 31 | } 32 | 33 | osIdentifier := goOSIdentifier() 34 | if !empty(osIdentifier) { 35 | identifiers = append(identifiers, osIdentifier) 36 | } 37 | 38 | for product, version := range agents { 39 | if empty(version) { 40 | identifiers = append(identifiers, product) 41 | } else { 42 | identifiers = append(identifiers, product+"/"+version) 43 | } 44 | } 45 | 46 | return strings.Join(identifiers, " ") 47 | } 48 | -------------------------------------------------------------------------------- /ably/proto_message_decoding_test.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "encoding/json" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/ably/ably-go/ably/internal/ablyutil" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | type fixture struct { 18 | Data string `json:"data"` 19 | Encoding string `json:"encoding"` 20 | ExpectedType string `json:"expectedType"` 21 | ExpectedValue any `json:"expectedValue"` 22 | ExpectedHexValue string `json:"expectedHexValue"` 23 | } 24 | 25 | func loadFixtures() ([]fixture, error) { 26 | var dec struct { 27 | Messages []fixture 28 | } 29 | 30 | // We can't embed the fixtures as go:embed forbids embedding of resources above the current directory. 31 | text, err := os.ReadFile("../common/test-resources/messages-encoding.json") 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | err = json.Unmarshal(text, &dec) 37 | return dec.Messages, err 38 | } 39 | 40 | func Test_decodeMessage(t *testing.T) { 41 | fixtures, err := loadFixtures() 42 | require.NoError(t, err, "failed to load test fixtures") 43 | for _, f := range fixtures { 44 | t.Run(f.Data, func(t *testing.T) { 45 | msg := Message{ 46 | Data: f.Data, 47 | Encoding: f.Encoding, 48 | } 49 | decodedMsg, err := msg.withDecodedData(nil) 50 | require.NoError(t, err) 51 | switch f.ExpectedType { 52 | case "string": 53 | assert.IsType(t, "string", decodedMsg.Data) 54 | assert.Equal(t, f.ExpectedValue, decodedMsg.Data) 55 | case "jsonObject": 56 | assert.IsType(t, map[string]any{}, decodedMsg.Data) 57 | assert.Equal(t, f.ExpectedValue, decodedMsg.Data) 58 | 59 | case "jsonArray": 60 | assert.IsType(t, []any{}, decodedMsg.Data) 61 | assert.Equal(t, f.ExpectedValue, decodedMsg.Data) 62 | case "binary": 63 | assert.IsType(t, []byte{}, decodedMsg.Data) 64 | if f.ExpectedHexValue != "" { 65 | value, err := hex.DecodeString(f.ExpectedHexValue) 66 | require.NoError(t, err) 67 | assert.Equal(t, value, decodedMsg.Data) 68 | } else if f.ExpectedValue != nil { 69 | assert.Equal(t, []byte(f.ExpectedValue.(string)), decodedMsg.Data) 70 | } 71 | } 72 | 73 | // Test that the re-encoding of the decoded message gives us back the original fixture. 74 | reEncoded, err := decodedMsg.withEncodedData(nil) 75 | require.NoError(t, err) 76 | assert.Equal(t, f.Encoding, reEncoded.Encoding) 77 | if f.Encoding == "json" { 78 | require.IsType(t, "", reEncoded.Data) 79 | // json fields could be re-ordered so assert.Equal could fail. 80 | assert.JSONEq(t, f.Data, reEncoded.Data.(string)) 81 | } else { 82 | assert.Equal(t, f.Data, reEncoded.Data) 83 | } 84 | }) 85 | } 86 | } 87 | 88 | type MsgpackTestFixture struct { 89 | Name string `json:"name"` 90 | Data any `json:"data"` 91 | Encoding string `json:"encoding"` 92 | NumRepeat int `json:"numRepeat"` 93 | Type string `json:"type"` 94 | MsgPack string `json:"msgpack"` 95 | } 96 | 97 | func loadMsgpackFixtures() ([]MsgpackTestFixture, error) { 98 | var o []MsgpackTestFixture 99 | 100 | // We can't embed the fixtures as go:embed forbids embedding of resources above the current directory. 101 | text, err := os.ReadFile("../common/test-resources/msgpack_test_fixtures.json") 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | err = json.Unmarshal(text, &o) 107 | return o, err 108 | } 109 | 110 | func TestMsgpackExtrasJsonCompatible(t *testing.T) { 111 | p := protocolMessage{ 112 | Messages: []*Message{ 113 | { 114 | Name: "my event", 115 | Data: "hello world", 116 | Extras: map[string]interface{}{ 117 | "headers": map[string]interface{}{ 118 | "version": 1, 119 | }, 120 | }, 121 | }, 122 | }, 123 | } 124 | 125 | b, err := ablyutil.MarshalMsgpack(p) 126 | assert.NoError(t, err) 127 | 128 | var got ProtocolMessage 129 | assert.NoError(t, ablyutil.UnmarshalMsgpack(b, &got)) 130 | 131 | msg, err := got.Messages[0].withDecodedData(nil) 132 | assert.NoError(t, err) 133 | 134 | buff := bytes.Buffer{} 135 | assert.NoError(t, json.NewEncoder(&buff).Encode(msg)) 136 | } 137 | 138 | func TestMsgpackDecoding(t *testing.T) { 139 | msgpackTestFixtures, err := loadMsgpackFixtures() 140 | require.NoError(t, err) 141 | 142 | for _, f := range msgpackTestFixtures { 143 | t.Run(f.Name, func(t *testing.T) { 144 | msgpackData := make([]byte, len(f.MsgPack)) 145 | n, err := base64.StdEncoding.Decode(msgpackData, []byte(f.MsgPack)) 146 | require.NoError(t, err) 147 | msgpackData = msgpackData[:n] 148 | 149 | var protoMsg ProtocolMessage 150 | err = ablyutil.UnmarshalMsgpack(msgpackData, &protoMsg) 151 | require.NoError(t, err) 152 | 153 | msg := protoMsg.Messages[0] 154 | decodedMsg, err := msg.withDecodedData(nil) 155 | switch f.Type { 156 | case "string": 157 | require.IsType(t, "string", decodedMsg.Data) 158 | assert.Equal(t, f.NumRepeat, len(decodedMsg.Data.(string))) 159 | assert.Equal(t, strings.Repeat(f.Data.(string), f.NumRepeat), decodedMsg.Data.(string)) 160 | case "binary": 161 | require.IsType(t, []byte{}, decodedMsg.Data) 162 | assert.Equal(t, f.NumRepeat, len(decodedMsg.Data.([]byte))) 163 | assert.Equal(t, bytes.Repeat([]byte(f.Data.(string)), f.NumRepeat), decodedMsg.Data.([]byte)) 164 | case "jsonObject", "jsonArray": 165 | assert.Equal(t, f.Data, decodedMsg.Data) 166 | } 167 | 168 | // Now re-encode and check that we get back the original message. 169 | reencodedMsg, err := msg.withEncodedData(nil) 170 | require.NoError(t, err) 171 | newMsg := ProtocolMessage{ 172 | Messages: []*Message{&reencodedMsg}, 173 | } 174 | newBytes, err := ablyutil.MarshalMsgpack(newMsg) 175 | require.NoError(t, err) 176 | assert.Equal(t, msgpackData, newBytes) 177 | 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /ably/proto_message_doc.txt: -------------------------------------------------------------------------------- 1 | README 2 | ====== 3 | 4 | This is the output of generate-test-data.js. Tests are based on the following output. 5 | 6 | Generating key: 7 | 00000000: b325 0600 5c9d 0027 817d df81 f37f aaa7 3%..\..'.}_.s.*' 8 | (16 bytes) 9 | 10 | 11 | Generating iv: 12 | 00000000: 88c2 93d8 819d 6453 753e b966 ab02 2993 .B.X..dSu>9f+.). 13 | (16 bytes) 14 | 15 | 16 | ******************************************************************* 17 | Generating test data for: { string: 'utf-8™' } 18 | 19 | 20 | Unencoded message: 21 | { name: 'example', data: { string: 'utf-8™' } } 22 | 23 | 24 | Encoded message: 25 | { name: 'example', 26 | data: '{"string":"utf-8™"}', 27 | encoding: 'json' } 28 | 29 | 30 | Unencrypted JSON message format: 31 | {"name":"example","data":"{\"string\":\"utf-8™\"}","encoding":"json"} 32 | 33 | 34 | Unencrypted msgpack message format: 35 | 00000000: 83a4 6e61 6d65 a765 7861 6d70 6c65 a464 .$name'example$d 36 | 00000010: 6174 61b5 7b22 7374 7269 6e67 223a 2275 ata5{"string":"u 37 | 00000020: 7466 2d38 e284 a222 7da8 656e 636f 6469 tf-8b.""}(encodi 38 | 00000030: 6e67 a46a 736f 6e ng$json 39 | (55 bytes) 40 | 41 | 42 | Plaintext before encryption (without padding): 43 | 00000000: 7b22 7374 7269 6e67 223a 2275 7466 2d38 {"string":"utf-8 44 | 00000010: e284 a222 7d b.""} 45 | (21 bytes) 46 | 47 | 48 | Plaintext before encryption (including padding): 49 | 00000000: 7b22 7374 7269 6e67 223a 2275 7466 2d38 {"string":"utf-8 50 | 00000010: e284 a222 7d0b 0b0b 0b0b 0b0b 0b0b 0b0b b.""}........... 51 | (32 bytes) 52 | 53 | 54 | Raw cipher output: 55 | 00000000: cd5c bad4 a9ba 2b87 3da1 3622 b462 44f6 M\:T):+.=!6"4bDv 56 | 00000010: e83b e528 22bf dc01 8d22 df3d 2850 eb36 h;e("?\.."_=(Pk6 57 | (32 bytes) 58 | 59 | 60 | Encrypted value, iv + cipher output: 61 | 00000000: 88c2 93d8 819d 6453 753e b966 ab02 2993 .B.X..dSu>9f+.). 62 | 00000010: cd5c bad4 a9ba 2b87 3da1 3622 b462 44f6 M\:T):+.=!6"4bDv 63 | 00000020: e83b e528 22bf dc01 8d22 df3d 2850 eb36 h;e("?\.."_=(Pk6 64 | (48 bytes) 65 | 66 | 67 | Encrypted message format: 68 | { name: 'example', 69 | data: , 70 | encoding: 'json/utf-8/cipher+aes-128-cbc' } 71 | 72 | 73 | Encrypted JSON message format: 74 | {"name":"example","data":"iMKT2IGdZFN1PrlmqwIpk81cutSpuiuHPaE2IrRiRPboO+UoIr/cAY0i3z0oUOs2","encoding":"json/utf-8/cipher+aes-128-cbc/base64"} 75 | 76 | 77 | Encrypted msgpack message format: 78 | 00000000: 83a4 6e61 6d65 a765 7861 6d70 6c65 a464 .$name'example$d 79 | 00000010: 6174 61c4 3088 c293 d881 9d64 5375 3eb9 ataD0.B.X..dSu>9 80 | 00000020: 66ab 0229 93cd 5cba d4a9 ba2b 873d a136 f+.).M\:T):+.=!6 81 | 00000030: 22b4 6244 f6e8 3be5 2822 bfdc 018d 22df "4bDvh;e("?\.."_ 82 | 00000040: 3d28 50eb 36a8 656e 636f 6469 6e67 bd6a =(Pk6(encoding=j 83 | 00000050: 736f 6e2f 7574 662d 382f 6369 7068 6572 son/utf-8/cipher 84 | 00000060: 2b61 6573 2d31 3238 2d63 6263 +aes-128-cbc 85 | (108 bytes) 86 | -------------------------------------------------------------------------------- /ably/proto_message_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build !unit 2 | // +build !unit 3 | 4 | package ably_test 5 | 6 | import ( 7 | "encoding/json" 8 | "testing" 9 | 10 | "github.com/ably/ably-go/ably" 11 | "github.com/ably/ably-go/ablytest" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestMessage_CryptoDataFixtures_RSL6a1_RSL5b_RSL5c(t *testing.T) { 17 | fixtures := []struct { 18 | desc, file string 19 | keylength int 20 | }{ 21 | {"with a 128 keylength", "test-resources/crypto-data-128.json", 128}, 22 | {"with a 256 keylength", "test-resources/crypto-data-256.json", 126}, 23 | } 24 | 25 | for _, fixture := range fixtures { 26 | // pin 27 | fixture := fixture 28 | t.Run(fixture.desc, func(t *testing.T) { 29 | test, key, iv, err := ablytest.LoadCryptoData(fixture.file) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | params := ably.CipherParams{ 34 | Algorithm: ably.CipherAES, 35 | Key: key, 36 | } 37 | params.SetIV(iv) 38 | opts := &ably.ProtoChannelOptions{ 39 | Cipher: params, 40 | } 41 | t.Run("fixture encode", func(t *testing.T) { 42 | for _, item := range test.Items { 43 | cipher, _ := opts.GetCipher() 44 | 45 | var encoded ably.Message 46 | err := json.Unmarshal(item.Encoded, &encoded) 47 | assert.NoError(t, err) 48 | 49 | encoded, err = ably.MessageWithDecodedData(encoded, cipher) 50 | assert.NoError(t, err) 51 | 52 | var encrypted ably.Message 53 | err = json.Unmarshal(item.Encoded, &encrypted) 54 | assert.NoError(t, err) 55 | 56 | encrypted, err = ably.MessageWithDecodedData(encrypted, cipher) 57 | assert.NoError(t, err) 58 | assert.Equal(t, encoded.Name, encrypted.Name, 59 | "expected %s got %s", encoded.Name, encrypted.Name) 60 | assert.Equal(t, encoded.Data, encrypted.Data, 61 | "expected %s got %s :encoding %s", encoded.Data, encrypted.Data, encoded.Encoding) 62 | } 63 | }) 64 | }) 65 | } 66 | } 67 | 68 | func TestMessage_CryptoDataFixtures_RSL6a1_RSL5b_RSL5c_TM3(t *testing.T) { 69 | fixtures := []struct { 70 | desc, file string 71 | keylength int 72 | }{ 73 | {"with a 128 keylength", "test-resources/crypto-data-128.json", 128}, 74 | {"with a 256 keylength", "test-resources/crypto-data-256.json", 126}, 75 | } 76 | 77 | for _, fixture := range fixtures { 78 | // pin 79 | fixture := fixture 80 | t.Run(fixture.desc, func(t *testing.T) { 81 | test, key, iv, err := ablytest.LoadCryptoData(fixture.file) 82 | assert.NoError(t, err) 83 | 84 | params := ably.CipherParams{ 85 | Algorithm: ably.CipherAES, 86 | Key: key, 87 | } 88 | params.SetIV(iv) 89 | opts := &ably.ProtoChannelOptions{ 90 | Cipher: params, 91 | } 92 | t.Run("fixture encode", func(t *testing.T) { 93 | for _, item := range test.Items { 94 | cipher, _ := opts.GetCipher() 95 | 96 | var encoded ably.Message 97 | err := json.Unmarshal(item.Encoded, &encoded) 98 | assert.NoError(t, err) 99 | 100 | encoded, err = ably.MessageWithDecodedData(encoded, cipher) 101 | assert.NoError(t, err) 102 | 103 | var encrypted ably.Message 104 | err = json.Unmarshal(item.Encoded, &encrypted) 105 | assert.NoError(t, err) 106 | 107 | encrypted, err = ably.MessageWithDecodedData(encrypted, cipher) 108 | assert.NoError(t, err) 109 | assert.Equal(t, encoded.Name, encrypted.Name) 110 | assert.Equal(t, encoded.Data, encrypted.Data) 111 | } 112 | }) 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ably/proto_message_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "encoding/base64" 8 | "encoding/json" 9 | "testing" 10 | 11 | "github.com/ably/ably-go/ably" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | type custom struct{} 17 | 18 | func (custom) MarshalJSON() ([]byte, error) { 19 | return json.Marshal([]string{"custom"}) 20 | } 21 | 22 | func TestMessage_EncodeDecode_TM3(t *testing.T) { 23 | key, err := base64.StdEncoding.DecodeString("WUP6u0K7MXI5Zeo0VppPwg==") 24 | assert.NoError(t, err) 25 | iv, err := base64.StdEncoding.DecodeString("HO4cYSP8LybPYBPZPHQOtg==") 26 | assert.NoError(t, err) 27 | params := ably.CipherParams{ 28 | Key: key, 29 | KeyLength: 128, 30 | Algorithm: ably.CipherAES, 31 | } 32 | params.SetIV(iv) 33 | opts := &ably.ProtoChannelOptions{ 34 | Cipher: params, 35 | } 36 | sample := []struct { 37 | desc string 38 | data interface{} 39 | opts *ably.ProtoChannelOptions 40 | encodedJSON string 41 | decoded interface{} 42 | }{ 43 | { 44 | // utf-8 string data should not have an encoding set, see: 45 | // https://github.com/ably/docs/issues/1165 46 | desc: "with valid utf-8 string data", 47 | data: "a string", 48 | decoded: "a string", 49 | encodedJSON: `{"data":"a string"}`, 50 | }, 51 | { 52 | // invalid utf-8 string data should be base64 encoded 53 | desc: "with invalid utf-8 string data", 54 | data: "\xf0\x80\x80", 55 | decoded: []byte("\xf0\x80\x80"), 56 | encodedJSON: `{"data":"8ICA","encoding":"base64"}`, 57 | }, 58 | { 59 | desc: "with a json encoding RSL4d3 map data", 60 | data: map[string]interface{}{ 61 | "string": ably.EncUTF8, 62 | }, 63 | decoded: map[string]interface{}{ 64 | "string": ably.EncUTF8, 65 | }, 66 | encodedJSON: `{"data":"{\"string\":\"utf-8\"}","encoding":"json"}`, 67 | }, 68 | { 69 | desc: "with a json encoding RSL4d3 array data", 70 | data: []int64{1, 2, 3}, 71 | decoded: []interface{}{ 72 | float64(1.0), 73 | float64(2.0), 74 | float64(3.0), 75 | }, 76 | encodedJSON: `{"data":"[1,2,3]","encoding":"json"}`, 77 | }, 78 | { 79 | desc: "with a json encoding RSL4d3 json.Marshaler data", 80 | data: custom{}, 81 | encodedJSON: `{"data":"[\"custom\"]","encoding":"json"}`, 82 | decoded: []interface{}{"custom"}, 83 | }, 84 | { 85 | desc: "with a base64 encoding RSL4d3 binary data", 86 | data: []byte(ably.EncBase64), 87 | decoded: []byte(ably.EncBase64), 88 | encodedJSON: `{"data":"YmFzZTY0","encoding":"base64"}`, 89 | }, 90 | { 91 | desc: "with json/utf-8/cipher+aes-128-cbc/base64", 92 | data: map[string]interface{}{ 93 | "string": `The quick brown fox jumped over the lazy dog`, 94 | }, 95 | decoded: map[string]interface{}{ 96 | "string": `The quick brown fox jumped over the lazy dog`, 97 | }, 98 | opts: opts, 99 | encodedJSON: `{"data":"HO4cYSP8LybPYBPZPHQOtlT0v5P4AF9H1o0CEftPkErqe+ebUOoIPB9eMrSy092XGb9jaq3PdU2qLwz1lRqtEuUMgX8zDmtkTkweJEpE81Y=","encoding":"json/utf-8/cipher+aes-128-cbc/base64"}`, 100 | }, 101 | } 102 | 103 | for _, v := range sample { 104 | // pin 105 | v := v 106 | t.Run(v.desc, func(t *testing.T) { 107 | cipher, _ := v.opts.GetCipher() 108 | msg, err := ably.MessageWithEncodedData(ably.Message{ 109 | Data: v.data, 110 | }, cipher) 111 | assert.NoError(t, err) 112 | b, err := json.Marshal(msg) 113 | assert.NoError(t, err) 114 | assert.Equal(t, v.encodedJSON, string(b), 115 | "expected %s got %s", v.encodedJSON, string(b)) 116 | 117 | var encoded ably.Message 118 | err = json.Unmarshal(b, &encoded) 119 | assert.NoError(t, err) 120 | decoded, err := ably.MessageWithDecodedData(encoded, cipher) 121 | assert.NoError(t, err) 122 | assert.Equal(t, v.decoded, decoded.Data, 123 | "expected %#v got %#v", v.decoded, decoded.Data) 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /ably/proto_presence_message.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // PresenceAction describes the possible actions members in the presence set can emit (TP2). 10 | type PresenceAction int64 11 | 12 | const ( 13 | // PresenceActionAbsent specifies a member is not present in the channel. 14 | PresenceActionAbsent PresenceAction = iota 15 | // PresenceActionPresent denotes when subscribing to presence events on a channel that already has members present, 16 | // this event is emitted for every member already present on the channel before the subscribe listener was registered. 17 | PresenceActionPresent 18 | // PresenceActionEnter denotes that new member has entered the channel. 19 | PresenceActionEnter 20 | // PresenceActionLeave is a member who was present has now left the channel. This may be a result of an explicit 21 | // request to leave or implicitly when detaching from the channel. Alternatively, if a member's connection is 22 | // abruptly disconnected and they do not resume their connection within a minute, Ably treats this as a 23 | // leave event as the client is no longer present. 24 | PresenceActionLeave 25 | // PresenceActionUpdate is a already present member has updated their member data. Being notified of member data 26 | // updates can be very useful, for example, it can be used to update the status of a user when they are 27 | // typing a message. 28 | PresenceActionUpdate 29 | ) 30 | 31 | func (e PresenceAction) String() string { 32 | switch e { 33 | case PresenceActionAbsent: 34 | return "ABSENT" 35 | case PresenceActionPresent: 36 | return "PRESENT" 37 | case PresenceActionEnter: 38 | return "ENTER" 39 | case PresenceActionLeave: 40 | return "LEAVE" 41 | case PresenceActionUpdate: 42 | return "UPDATE" 43 | } 44 | return "" 45 | } 46 | 47 | func (PresenceAction) isEmitterEvent() {} 48 | 49 | type PresenceMessage struct { 50 | Message 51 | Action PresenceAction `json:"action" codec:"action"` 52 | } 53 | 54 | func (m PresenceMessage) String() string { 55 | return fmt.Sprintf("", [...]string{ 56 | "absent", 57 | "present", 58 | "enter", 59 | "leave", 60 | "update", 61 | }[m.Action], m.ID, m.ConnectionID, m.ClientID, m.Data) 62 | } 63 | 64 | func (msg *PresenceMessage) isServerSynthesized() bool { 65 | return !strings.HasPrefix(msg.ID, msg.ConnectionID) 66 | } 67 | 68 | func (msg *PresenceMessage) getMsgSerialAndIndex() (int64, int64, error) { 69 | msgIds := strings.Split(msg.ID, ":") 70 | if len(msgIds) != 3 { 71 | return 0, 0, fmt.Errorf("parsing error, the presence message has invalid id %v", msg.ID) 72 | } 73 | msgSerial, err := strconv.ParseInt(msgIds[1], 10, 64) 74 | if err != nil { 75 | return 0, 0, fmt.Errorf("parsing error, the presence message has invalid msgSerial, for msgId %v", msg.ID) 76 | } 77 | msgIndex, err := strconv.ParseInt(msgIds[2], 10, 64) 78 | if err != nil { 79 | return 0, 0, fmt.Errorf("parsing error, the presence message has invalid msgIndex, for msgId %v", msg.ID) 80 | } 81 | return msgSerial, msgIndex, nil 82 | } 83 | 84 | // RTP2b, RTP2c 85 | func (msg1 *PresenceMessage) IsNewerThan(msg2 *PresenceMessage) (bool, error) { 86 | // RTP2b1 87 | if msg1.isServerSynthesized() || msg2.isServerSynthesized() { 88 | return msg1.Timestamp >= msg2.Timestamp, nil 89 | } 90 | 91 | // RTP2b2 92 | msg1Serial, msg1Index, err := msg1.getMsgSerialAndIndex() 93 | if err != nil { 94 | return false, err 95 | } 96 | msg2Serial, msg2Index, err := msg2.getMsgSerialAndIndex() 97 | if err != nil { 98 | return true, err 99 | } 100 | if msg1Serial == msg2Serial { 101 | return msg1Index > msg2Index, nil 102 | } 103 | return msg1Serial > msg2Serial, nil 104 | } 105 | -------------------------------------------------------------------------------- /ably/proto_protocol_message_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "bytes" 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/ably/ably-go/ably" 12 | "github.com/ably/ably-go/ably/internal/ablyutil" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | // TestProtocolMessageEncodeZeroSerials tests that zero-valued serials are 18 | // explicitly encoded into msgpack (as required by the realtime API) 19 | func TestProtocolMessageEncodeZeroSerials(t *testing.T) { 20 | msg := ably.ProtocolMessage{ 21 | ID: "test", 22 | MsgSerial: 0, 23 | } 24 | encoded, err := ablyutil.MarshalMsgpack(msg) 25 | assert.NoError(t, err) 26 | // expect a 2-element map with both the serial fields set to zero 27 | expected := []byte("\x82\xa2id\xa4test\xa9msgSerial\x00") 28 | assert.True(t, bytes.Equal(encoded, expected), 29 | "unexpected msgpack encoding\nexpected: %x\nactual: %x", expected, encoded) 30 | } 31 | 32 | func TestUpdateEmptyMessageFields_TM2a_TM2c_TM2f(t *testing.T) { 33 | messages := []*ably.Message{ 34 | { 35 | ID: "", 36 | ConnectionID: "", 37 | Timestamp: 0, 38 | }, 39 | { 40 | ID: "", 41 | ConnectionID: "", 42 | Timestamp: 0, 43 | }, 44 | { 45 | ID: "", 46 | ConnectionID: "", 47 | Timestamp: 0, 48 | }, 49 | } 50 | 51 | presenceMessages := []*ably.PresenceMessage{ 52 | { 53 | Message: ably.Message{ 54 | ID: "", 55 | ConnectionID: "", 56 | Timestamp: 0, 57 | }, 58 | Action: 0, 59 | }, 60 | { 61 | Message: ably.Message{ 62 | ID: "", 63 | ConnectionID: "", 64 | Timestamp: 0, 65 | }, 66 | Action: 0, 67 | }, 68 | { 69 | Message: ably.Message{ 70 | ID: "", 71 | ConnectionID: "", 72 | Timestamp: 0, 73 | }, 74 | Action: 0, 75 | }, 76 | { 77 | Message: ably.Message{ 78 | ID: "", 79 | ConnectionID: "", 80 | Timestamp: 0, 81 | }, 82 | Action: 0, 83 | }, 84 | } 85 | protoMsg := ably.ProtocolMessage{ 86 | Messages: messages, 87 | Presence: presenceMessages, 88 | ID: "msg-id", 89 | ConnectionID: "conn-id", 90 | Timestamp: 3453, 91 | } 92 | 93 | protoMsg.UpdateEmptyFields() 94 | 95 | for msgIndex, msg := range protoMsg.Messages { 96 | assert.Equal(t, protoMsg.ID+":"+strconv.Itoa(msgIndex), msg.ID) 97 | assert.Equal(t, protoMsg.ConnectionID, msg.ConnectionID) 98 | assert.Equal(t, protoMsg.Timestamp, msg.Timestamp) 99 | } 100 | 101 | for presenceMsgIndex, presenceMessage := range protoMsg.Presence { 102 | assert.Equal(t, protoMsg.ID+":"+strconv.Itoa(presenceMsgIndex), presenceMessage.ID) 103 | assert.Equal(t, protoMsg.ConnectionID, presenceMessage.ConnectionID) 104 | assert.Equal(t, protoMsg.Timestamp, presenceMessage.Timestamp) 105 | } 106 | } 107 | 108 | func TestIfFlagIsSet(t *testing.T) { 109 | flags := ably.FlagAttachResume 110 | flags.Set(ably.FlagPresence) 111 | flags.Set(ably.FlagPublish) 112 | flags.Set(ably.FlagSubscribe) 113 | flags.Set(ably.FlagPresenceSubscribe) 114 | 115 | assert.Equal(t, ably.FlagPresence, flags&ably.FlagPresence, 116 | "Expected %v, actual %v", ably.FlagPresence, flags&ably.FlagPresence) 117 | assert.Equal(t, ably.FlagPublish, flags&ably.FlagPublish, 118 | "Expected %v, actual %v", ably.FlagPublish, flags&ably.FlagPublish) 119 | assert.Equal(t, ably.FlagSubscribe, flags&ably.FlagSubscribe, 120 | "Expected %v, actual %v", ably.FlagSubscribe, flags&ably.FlagSubscribe) 121 | assert.Equal(t, ably.FlagPresenceSubscribe, flags&ably.FlagPresenceSubscribe, 122 | "Expected %v, actual %v", ably.FlagPresenceSubscribe, flags&ably.FlagPresenceSubscribe) 123 | assert.Equal(t, ably.FlagAttachResume, flags&ably.FlagAttachResume, 124 | "Expected %v, actual %v", ably.FlagAttachResume, flags&ably.FlagAttachResume) 125 | assert.NotEqual(t, ably.FlagHasBacklog, flags&ably.FlagAttachResume, 126 | "Shouldn't contain flag %v", ably.FlagHasBacklog) 127 | } 128 | 129 | func TestIfHasFlg(t *testing.T) { 130 | flags := ably.FlagAttachResume | ably.FlagPresence | ably.FlagPublish 131 | assert.True(t, flags.Has(ably.FlagAttachResume), 132 | "Should contain flag %v", ably.FlagAttachResume) 133 | assert.True(t, flags.Has(ably.FlagPresence), 134 | "Should contain flag %v", ably.FlagPresence) 135 | assert.True(t, flags.Has(ably.FlagPublish), 136 | "Should contain flag %v", ably.FlagPublish) 137 | assert.False(t, flags.Has(ably.FlagHasBacklog), 138 | "Shouldn't contain flag %v", ably.FlagHasBacklog) 139 | } 140 | -------------------------------------------------------------------------------- /ably/proto_types.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/ugorji/go/codec" 8 | ) 9 | 10 | // durationFromMsecs is a time.Duration that is marshaled as a JSON whole number of milliseconds. 11 | type durationFromMsecs time.Duration 12 | 13 | var _ interface { 14 | json.Marshaler 15 | json.Unmarshaler 16 | codec.Selfer 17 | } = (*durationFromMsecs)(nil) 18 | 19 | func (t durationFromMsecs) asMsecs() int64 { 20 | return time.Duration(t).Milliseconds() 21 | } 22 | 23 | func (t *durationFromMsecs) setFromMsecs(ms int64) { 24 | *t = durationFromMsecs(time.Duration(ms) * time.Millisecond) 25 | } 26 | 27 | func (t durationFromMsecs) MarshalJSON() ([]byte, error) { 28 | return json.Marshal(t.asMsecs()) 29 | } 30 | 31 | func (t *durationFromMsecs) UnmarshalJSON(js []byte) error { 32 | var ms int64 33 | err := json.Unmarshal(js, &ms) 34 | if err != nil { 35 | return err 36 | } 37 | t.setFromMsecs(ms) 38 | return nil 39 | } 40 | 41 | func (t durationFromMsecs) CodecEncodeSelf(encoder *codec.Encoder) { 42 | encoder.MustEncode(t.asMsecs()) 43 | } 44 | 45 | func (t *durationFromMsecs) CodecDecodeSelf(decoder *codec.Decoder) { 46 | var ms int64 47 | decoder.MustDecode(&ms) 48 | t.setFromMsecs(ms) 49 | } 50 | -------------------------------------------------------------------------------- /ably/proto_types_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "encoding/json" 8 | "testing" 9 | "time" 10 | 11 | "github.com/ably/ably-go/ably" 12 | "github.com/ably/ably-go/ably/internal/ablyutil" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestDurationFromMsecsMarshal(t *testing.T) { 18 | 19 | for _, codec := range []struct { 20 | name string 21 | marshal func(interface{}) ([]byte, error) 22 | unmarshal func([]byte, interface{}) error 23 | }{ 24 | {"JSON", json.Marshal, json.Unmarshal}, 25 | {"Msgpack", ablyutil.MarshalMsgpack, ablyutil.UnmarshalMsgpack}, 26 | } { 27 | t.Run(codec.name, func(t *testing.T) { 28 | 29 | js, err := codec.marshal(ably.DurationFromMsecs((123456 * time.Millisecond))) 30 | assert.NoError(t, err) 31 | 32 | var msecs int64 33 | err = codec.unmarshal(js, &msecs) 34 | assert.NoError(t, err) 35 | assert.Equal(t, int64(123456), msecs, "expected marshaling as JSON number of milliseconds; got %d (JSON: %q)", msecs, js) 36 | 37 | var decoded ably.DurationFromMsecs 38 | err = codec.unmarshal(js, &decoded) 39 | assert.NoError(t, err) 40 | assert.Equal(t, (123456 * time.Millisecond), time.Duration(decoded), 41 | "expected json.Unmarshal after Marshal to produce the same duration; got %v", time.Duration(decoded)) 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ably/realtime_channel_internal_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestChannelOptionChannelWithCipherKey(t *testing.T) { 13 | tests := map[string]struct { 14 | key []byte 15 | expectedResult *channelOptions 16 | }{ 17 | "Can inject a cipher key of length 128 into cipher params": { 18 | key: []byte{82, 27, 7, 33, 130, 101, 79, 22, 63, 95, 15, 154, 98, 29, 114, 19}, 19 | expectedResult: &channelOptions{ 20 | Cipher: CipherParams{ 21 | Algorithm: CipherAES, 22 | KeyLength: 128, 23 | Key: []uint8{0x52, 0x1b, 0x7, 0x21, 0x82, 0x65, 0x4f, 0x16, 0x3f, 0x5f, 0xf, 0x9a, 0x62, 0x1d, 0x72, 0x13}, 24 | Mode: CipherCBC, 25 | }, 26 | }, 27 | }, 28 | 29 | "Can inject a cipher key of length 256 into cipher params": { 30 | key: []byte{82, 27, 7, 33, 130, 101, 79, 22, 63, 95, 15, 154, 98, 29, 114, 19, 10, 23, 45, 56, 76, 29, 111, 23, 93, 22, 44, 66, 88, 43, 72, 42}, 31 | expectedResult: &channelOptions{ 32 | Cipher: CipherParams{ 33 | Algorithm: CipherAES, 34 | KeyLength: 256, 35 | Key: []uint8{0x52, 0x1b, 0x7, 0x21, 0x82, 0x65, 0x4f, 0x16, 0x3f, 0x5f, 0xf, 0x9a, 0x62, 0x1d, 0x72, 0x13, 0xa, 0x17, 0x2d, 0x38, 0x4c, 0x1d, 0x6f, 0x17, 0x5d, 0x16, 0x2c, 0x42, 0x58, 0x2b, 0x48, 0x2a}, 36 | Mode: CipherCBC, 37 | }, 38 | }, 39 | }, 40 | } 41 | 42 | for testName, test := range tests { 43 | t.Run(testName, func(t *testing.T) { 44 | opt := ChannelWithCipherKey(test.key) 45 | result := applyChannelOptions(opt) 46 | assert.Equal(t, test.expectedResult, result) 47 | }) 48 | } 49 | } 50 | 51 | func TestChannelOptionChannelWithCipher(t *testing.T) { 52 | tests := map[string]struct { 53 | params CipherParams 54 | expectedResult *channelOptions 55 | }{ 56 | "Can set cipher params as channel options": { 57 | params: CipherParams{ 58 | Algorithm: CipherAES, 59 | KeyLength: 128, 60 | Key: []uint8{0x52, 0x1b, 0x7, 0x21, 0x82, 0x65, 0x4f, 0x16, 0x3f, 0x5f, 0xf, 0x9a, 0x62, 0x1d, 0x72, 0x13}, 61 | Mode: CipherCBC, 62 | }, 63 | expectedResult: &channelOptions{ 64 | Cipher: CipherParams{ 65 | Algorithm: CipherAES, 66 | KeyLength: 128, 67 | Key: []uint8{0x52, 0x1b, 0x7, 0x21, 0x82, 0x65, 0x4f, 0x16, 0x3f, 0x5f, 0xf, 0x9a, 0x62, 0x1d, 0x72, 0x13}, 68 | Mode: CipherCBC, 69 | }, 70 | }, 71 | }, 72 | } 73 | 74 | for testName, test := range tests { 75 | t.Run(testName, func(t *testing.T) { 76 | opt := ChannelWithCipher(test.params) 77 | result := applyChannelOptions(opt) 78 | assert.Equal(t, test.expectedResult, result) 79 | }) 80 | } 81 | } 82 | 83 | func TestChannelOptionChannelWithParams(t *testing.T) { 84 | tests := map[string]struct { 85 | key string 86 | value string 87 | expectedResult *channelOptions 88 | }{ 89 | "Can set a key and a value as channel options": { 90 | key: "aKey", 91 | value: "aValue", 92 | expectedResult: &channelOptions{ 93 | Params: map[string]string{"aKey": "aValue"}, 94 | }, 95 | }, 96 | } 97 | 98 | for testName, test := range tests { 99 | t.Run(testName, func(t *testing.T) { 100 | opt := ChannelWithParams(test.key, test.value) 101 | result := applyChannelOptions(opt) 102 | assert.Equal(t, test.expectedResult, result) 103 | }) 104 | } 105 | } 106 | 107 | func TestChannelOptionChannelWithModes(t *testing.T) { 108 | tests := map[string]struct { 109 | modes []ChannelMode 110 | expectedResult *channelOptions 111 | }{ 112 | "Can set a channel mode as channel options": { 113 | modes: []ChannelMode{ChannelModePresence}, 114 | expectedResult: &channelOptions{ 115 | Modes: []ChannelMode{ChannelModePresence}, 116 | }, 117 | }, 118 | "Can set multiple channel mode as channel options": { 119 | modes: []ChannelMode{ChannelModePresence, ChannelModePublish}, 120 | expectedResult: &channelOptions{ 121 | Modes: []ChannelMode{ChannelModePresence, ChannelModePublish}, 122 | }, 123 | }, 124 | } 125 | 126 | for testName, test := range tests { 127 | t.Run(testName, func(t *testing.T) { 128 | opt := ChannelWithModes(test.modes...) 129 | result := applyChannelOptions(opt) 130 | assert.Equal(t, test.expectedResult, result) 131 | }) 132 | } 133 | } 134 | 135 | func TestChannelGet(t *testing.T) { 136 | tests := map[string]struct { 137 | mock *RealtimeChannels 138 | name string 139 | expectedChannelName string 140 | expectedChannelState ChannelState 141 | }{ 142 | "If channel does not exist, it is created and initialised": { 143 | mock: &RealtimeChannels{ 144 | chans: map[string]*RealtimeChannel{}, 145 | client: &Realtime{ 146 | rest: &REST{ 147 | log: logger{l: &stdLogger{mocklogger}}, 148 | }, 149 | }, 150 | }, 151 | name: "new", 152 | expectedChannelName: "new", 153 | expectedChannelState: ChannelStateInitialized, 154 | }, 155 | } 156 | 157 | for testName, test := range tests { 158 | t.Run(testName, func(t *testing.T) { 159 | result := test.mock.Get(test.name) 160 | assert.Equal(t, test.expectedChannelName, result.Name) 161 | assert.Equal(t, test.expectedChannelState, result.state) 162 | }) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /ably/realtime_channel_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/ably/ably-go/ably" 12 | ) 13 | 14 | // When publishing a message to a channel, data can be either a single string or 15 | // a struct of type Message. This example shows the different ways to publish a message. 16 | func ExampleRealtimeChannel_Publish() { 17 | 18 | // Create a new realtime client. 19 | client, err := ably.NewRealtime( 20 | ably.WithKey("ABLY_PRIVATE_KEY"), 21 | ably.WithClientID("Client A"), 22 | ) 23 | if err != nil { 24 | fmt.Println(err) 25 | return 26 | } 27 | 28 | // Initialise a new channel. 29 | channel := client.Channels.Get("chat") 30 | 31 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 32 | defer cancel() 33 | 34 | // Publish a string to the channel 35 | if err := channel.Publish(ctx, "chat_message", "Hello, how are you?"); err != nil { 36 | fmt.Println(err) 37 | return 38 | } 39 | 40 | // Publish a Message to the channel 41 | newChatMessage := ably.Message{ 42 | Name: "chat_message", 43 | Data: "Hello, how are you?", 44 | } 45 | 46 | if err := channel.Publish(ctx, "chat_message", newChatMessage); err != nil { 47 | fmt.Println(err) 48 | return 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ably/realtime_client.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // Realtime is an ably realtime client that extends the functionality of the [ably.REST] and provides 10 | // additional realtime-specific features. 11 | type Realtime struct { 12 | // An [ably.Auth] object (RTC4). 13 | Auth *Auth 14 | // A [ably.RealtimeChannels] object (RTC3, RTS1). 15 | Channels *RealtimeChannels 16 | // A [ably.Connection] object (RTC2). 17 | Connection *Connection 18 | rest *REST 19 | } 20 | 21 | // NewRealtime constructs a new [ably.Realtime] client object using an Ably [ably.ClientOption] object (RSC1) 22 | func NewRealtime(options ...ClientOption) (*Realtime, error) { 23 | c := &Realtime{} 24 | rest, err := NewREST(options...) //options validated in NewREST 25 | if err != nil { 26 | return nil, err 27 | } 28 | c.rest = rest 29 | c.Auth = rest.Auth 30 | c.Channels = newChannels(c) 31 | conn := newConn(c.opts(), rest.Auth, connCallbacks{ 32 | c.onChannelMsg, 33 | c.onReconnected, 34 | c.onReconnectionFailed, 35 | }, c) 36 | conn.internalEmitter.OnAll(func(change ConnectionStateChange) { 37 | c.Channels.broadcastConnStateChange(change) 38 | }) 39 | c.Connection = conn 40 | 41 | // RTN16 42 | if !empty(c.opts().Recover) { 43 | recoverKeyContext, err := DecodeRecoveryKey(c.opts().Recover) 44 | if err != nil { 45 | // Ignoring error since no recover will be used for new connection 46 | c.log().Errorf("Error decoding recover with error %v", err) 47 | c.log().Errorf("Trying a fresh connection instead") 48 | } else { 49 | c.Channels.SetChannelSerialsFromRecoverOption(recoverKeyContext.ChannelSerials) // RTN16j 50 | c.Connection.msgSerial = recoverKeyContext.MsgSerial // RTN16f 51 | } 52 | } 53 | return c, nil 54 | } 55 | 56 | // Connect calls Connection.Connect and causes the connection to open, entering the connecting state. 57 | // Explicitly calling Connect() is needed if the ClientOptions.NoConnect is set true (proxy for RTN11). 58 | func (c *Realtime) Connect() { 59 | c.Connection.Connect() 60 | } 61 | 62 | // Close calls Connection.Close and causes the connection to close, entering the closing state. Once closed, 63 | // the library will not attempt to re-establish the connection without an explicit call to Connection.Connect 64 | // proxy for RTN12 65 | func (c *Realtime) Close() { 66 | c.Connection.Close() 67 | } 68 | 69 | // Stats queries the REST /stats API and retrieves your application's usage statistics. 70 | // Returns a [ably.PaginatedResult] object, containing an array of [ably.Stats] objects (RTC5). 71 | // 72 | // See package-level documentation => [ably] Pagination for handling stats pagination. 73 | func (c *Realtime) Stats(o ...StatsOption) StatsRequest { 74 | return c.rest.Stats(o...) 75 | } 76 | 77 | // Time retrieves the time from the Ably service as milliseconds since the Unix epoch. 78 | // Clients that do not have access to a sufficiently well maintained time source and wish to issue Ably 79 | // multiple [ably.TokenRequest] with a more accurate timestamp should use the clientOptions.UseQueryTime property 80 | // instead of this method (RTC6a). 81 | func (c *Realtime) Time(ctx context.Context) (time.Time, error) { 82 | return c.rest.Time(ctx) 83 | } 84 | 85 | func (c *Realtime) onChannelMsg(msg *protocolMessage) { 86 | c.Channels.Get(msg.Channel).notify(msg) 87 | } 88 | 89 | func (c *Realtime) onReconnected(failedResumeOrRecover bool) { 90 | for _, ch := range c.Channels.Iterate() { 91 | switch ch.State() { 92 | // RTN15g3, RTN15c6, RTN15c7, RTN16l 93 | case ChannelStateAttaching, ChannelStateAttached, ChannelStateSuspended: 94 | ch.mayAttach(false) 95 | case ChannelStateDetaching: //RTN19b 96 | ch.detachSkipVerifyActive() 97 | } 98 | } 99 | 100 | if failedResumeOrRecover { //RTN19a1 101 | c.Connection.resendPending() 102 | } else { //RTN19a2 - successful resume, msgSerial doesn't change 103 | c.Connection.resendAcks() 104 | } 105 | } 106 | 107 | func (c *Realtime) onReconnectionFailed(err *errorInfo) { 108 | for _, ch := range c.Channels.Iterate() { 109 | ch.setState(ChannelStateFailed, newErrorFromProto(err), false) 110 | } 111 | } 112 | 113 | func isTokenError(err *errorInfo) bool { 114 | return err != nil && err.StatusCode == http.StatusUnauthorized && (40140 <= err.Code && err.Code < 40150) 115 | } 116 | 117 | func (c *Realtime) opts() *clientOptions { 118 | return c.rest.opts 119 | } 120 | 121 | func (c *Realtime) log() logger { 122 | return c.rest.log 123 | } 124 | -------------------------------------------------------------------------------- /ably/realtime_client_internal_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewRealtime(t *testing.T) { 14 | tests := map[string]struct { 15 | options []ClientOption 16 | expectedErr error 17 | }{ 18 | "Can handle invalid key error when WithKey option is not provided": { 19 | options: []ClientOption{}, 20 | expectedErr: &ErrorInfo{ 21 | StatusCode: 400, 22 | Code: 40005, 23 | err: errors.New("invalid key"), 24 | }, 25 | }, 26 | "Can create a new realtime client with a valid key": { 27 | options: []ClientOption{WithKey("abc:def")}, 28 | }, 29 | } 30 | 31 | for testName, test := range tests { 32 | t.Run(testName, func(t *testing.T) { 33 | 34 | client, err := NewRealtime(test.options...) 35 | assert.Equal(t, test.expectedErr, err) 36 | 37 | if client != nil { 38 | assert.Equal(t, "abc:def", client.Auth.opts().Key) 39 | // Assert all client fields have been populated 40 | assert.NotNil(t, client.rest) 41 | assert.NotNil(t, client.Auth) 42 | assert.NotNil(t, client.Channels) 43 | assert.NotNil(t, client.Channels.client) 44 | assert.NotNil(t, client.Channels.chans) 45 | assert.NotNil(t, client.Connection) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ably/realtime_presence_internal_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "errors" 10 | "log" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | var ( 17 | buffer bytes.Buffer 18 | mocklogger = log.New(&buffer, "logger: ", log.Lshortfile) 19 | ) 20 | 21 | // mockChannelWithState is a test helper that returns a mock channel in a specified state 22 | func mockChannelWithState(channelState *ChannelState, connectionState *ConnectionState) *RealtimeChannel { 23 | mockChannel := RealtimeChannel{ 24 | client: &Realtime{ 25 | rest: &REST{ 26 | log: logger{l: &stdLogger{mocklogger}}, 27 | }, 28 | Connection: &Connection{}, 29 | }, 30 | } 31 | if channelState != nil { 32 | mockChannel.state = *channelState 33 | } 34 | if connectionState != nil { 35 | mockChannel.client.Connection.state = *connectionState 36 | } 37 | return &mockChannel 38 | } 39 | 40 | func TestVerifyChanState_RTP16(t *testing.T) { 41 | tests := map[string]struct { 42 | channel *RealtimeChannel 43 | expectedErr error 44 | }{ 45 | `No error if the channel is in state: "INITIALIZED"`: { 46 | channel: mockChannelWithState(&ChannelStateInitialized, nil), 47 | expectedErr: nil, 48 | }, 49 | `No error if the channel is in state: "ATTACHING"`: { 50 | channel: mockChannelWithState(&ChannelStateAttaching, nil), 51 | expectedErr: nil, 52 | }, 53 | `No error if the channel is in state: "ATTACHED"`: { 54 | channel: mockChannelWithState(&ChannelStateAttached, nil), 55 | expectedErr: nil, 56 | }, 57 | `Error if the channel is in state: "SUSPENDED"`: { 58 | channel: mockChannelWithState(&ChannelStateSuspended, nil), 59 | expectedErr: newError(91001, errors.New("unable to enter presence channel (invalid channel state: SUSPENDED)")), 60 | }, 61 | `Error if the channel is in state: "DETACHING"`: { 62 | channel: mockChannelWithState(&ChannelStateDetaching, nil), 63 | expectedErr: newError(91001, errors.New("unable to enter presence channel (invalid channel state: DETACHING)")), 64 | }, 65 | `Error if the channel is in state: "DETACHED"`: { 66 | channel: mockChannelWithState(&ChannelStateDetached, nil), 67 | expectedErr: newError(91001, errors.New("unable to enter presence channel (invalid channel state: DETACHED)")), 68 | }, 69 | `Error if the channel is in state: "FAILED"`: { 70 | channel: mockChannelWithState(&ChannelStateFailed, nil), 71 | expectedErr: newError(91001, errors.New("unable to enter presence channel (invalid channel state: FAILED)")), 72 | }, 73 | } 74 | 75 | for testName, test := range tests { 76 | t.Run(testName, func(t *testing.T) { 77 | presence := newRealtimePresence(test.channel) 78 | err := presence.isValidChannelState() 79 | assert.Equal(t, test.expectedErr, err) 80 | }) 81 | } 82 | } 83 | 84 | func TestSend(t *testing.T) { 85 | tests := map[string]struct { 86 | channel *RealtimeChannel 87 | msg Message 88 | expectedResult result 89 | expectedErr error 90 | }{ 91 | `No error sending presence if the channel is in state: "ATTACHED"`: { 92 | channel: mockChannelWithState(&ChannelStateAttached, nil), 93 | msg: Message{Name: "Hello"}, 94 | expectedErr: (*ErrorInfo)(nil), 95 | }, 96 | `Error if channel is: "ATTACHED" and connection is :"CLOSED"`: { 97 | channel: mockChannelWithState(&ChannelStateAttached, &ConnectionStateClosed), 98 | msg: Message{Name: "Hello"}, 99 | expectedErr: newError(80017, errors.New("Connection unavailable")), 100 | }, 101 | `Error if channel is: "DETACHED" and connection is :"CLOSED"`: { 102 | channel: mockChannelWithState(&ChannelStateDetached, &ConnectionStateClosed), 103 | msg: Message{Name: "Hello"}, 104 | expectedErr: newError(91001, errors.New("unable to enter presence channel (invalid channel state: DETACHED)")), 105 | }, 106 | } 107 | 108 | for testName, test := range tests { 109 | t.Run(testName, func(t *testing.T) { 110 | presence := newRealtimePresence(test.channel) 111 | err := presence.EnterClient(context.Background(), "clientId", &test.msg) 112 | assert.Equal(t, test.expectedErr, err.(*ErrorInfo)) 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ably/recovery_context.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import "encoding/json" 4 | 5 | // RecoveryKeyContext contains the properties required to recover existing connection. 6 | type RecoveryKeyContext struct { 7 | ConnectionKey string `json:"connectionKey" codec:"connectionKey"` 8 | MsgSerial int64 `json:"msgSerial" codec:"msgSerial"` 9 | ChannelSerials map[string]string `json:"channelSerials" codec:"channelSerials"` 10 | } 11 | 12 | func (r *RecoveryKeyContext) Encode() (serializedRecoveryKey string, err error) { 13 | result, err := json.Marshal(r) 14 | if err != nil { 15 | serializedRecoveryKey = "" 16 | } else { 17 | serializedRecoveryKey = string(result) 18 | } 19 | return 20 | } 21 | 22 | func DecodeRecoveryKey(recoveryKey string) (rCtx *RecoveryKeyContext, err error) { 23 | err = json.Unmarshal([]byte(recoveryKey), &rCtx) 24 | if err != nil { 25 | rCtx = nil 26 | } 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /ably/recovery_context_test.go: -------------------------------------------------------------------------------- 1 | //go:build !unit 2 | // +build !unit 3 | 4 | package ably_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_ShouldEncodeRecoveryKeyContextObject(t *testing.T) { 14 | var expectedRecoveryKey = "{\"connectionKey\":\"uniqueKey\",\"msgSerial\":1,\"channelSerials\":{\"channel1\":\"1\",\"channel2\":\"2\",\"channel3\":\"3\"}}" 15 | var recoveryKey = &ably.RecoveryKeyContext{ 16 | ConnectionKey: "uniqueKey", 17 | MsgSerial: 1, 18 | ChannelSerials: map[string]string{ 19 | "channel1": "1", 20 | "channel2": "2", 21 | "channel3": "3", 22 | }, 23 | } 24 | key, err := recoveryKey.Encode() 25 | assert.Nil(t, err) 26 | assert.Equal(t, expectedRecoveryKey, key) 27 | } 28 | 29 | func Test_ShouldDecodeRecoveryKeyToRecoveryKeyContextObject(t *testing.T) { 30 | var recoveryKey = "{\"connectionKey\":\"uniqueKey\",\"msgSerial\":1,\"channelSerials\":{\"channel1\":\"1\",\"channel2\":\"2\",\"channel3\":\"3\"}}" 31 | keyContext, err := ably.DecodeRecoveryKey(recoveryKey) 32 | assert.Nil(t, err) 33 | assert.Equal(t, int64(1), keyContext.MsgSerial) 34 | assert.Equal(t, "uniqueKey", keyContext.ConnectionKey) 35 | assert.Equal(t, "1", keyContext.ChannelSerials["channel1"]) 36 | assert.Equal(t, "2", keyContext.ChannelSerials["channel2"]) 37 | assert.Equal(t, "3", keyContext.ChannelSerials["channel3"]) 38 | } 39 | 40 | func Test_ShouldReturnNullRecoveryContextWhileDecodingFaultyRecoveryKey(t *testing.T) { 41 | var recoveryKey = "{\"connectionKey\":\"uniqueKey\",\"msgSerial\":\"incorrectStringSerial\",\"channelSerials\":{\"channel1\":\"1\",\"channel2\":\"2\",\"channel3\":\"3\"}}" 42 | keyContext, err := ably.DecodeRecoveryKey(recoveryKey) 43 | assert.NotNil(t, err) 44 | assert.Nil(t, keyContext) 45 | } 46 | -------------------------------------------------------------------------------- /ably/rest_channel_spec_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build !unit 2 | // +build !unit 3 | 4 | package ably_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/ably/ably-go/ably" 13 | "github.com/ably/ably-go/ablytest" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestRSL1f1(t *testing.T) { 19 | app, err := ablytest.NewSandbox(nil) 20 | assert.NoError(t, err) 21 | defer app.Close() 22 | opts := app.Options() 23 | // RSL1f 24 | opts = append(opts, ably.WithUseTokenAuth(false)) 25 | client, err := ably.NewREST(opts...) 26 | assert.NoError(t, err) 27 | channel := client.Channels.Get("RSL1f") 28 | var msgs []*ably.Message 29 | size := 10 30 | for i := 0; i < size; i++ { 31 | msgs = append(msgs, &ably.Message{ 32 | ClientID: "any_client_id", 33 | Data: fmt.Sprint(i), 34 | }) 35 | } 36 | err = channel.PublishMultiple(context.Background(), msgs) 37 | assert.NoError(t, err) 38 | var m []*ably.Message 39 | err = ablytest.AllPages(&m, channel.History()) 40 | assert.NoError(t, err) 41 | assert.Equal(t, 10, len(m), 42 | "expected 10 messages got %d", len(m)) 43 | for _, v := range m { 44 | assert.Equal(t, "any_client_id", v.ClientID, 45 | "expected clientId \"any_client_id\" got %s data:%v", v.ClientID, v.Data) 46 | } 47 | } 48 | 49 | func TestRSL1g(t *testing.T) { 50 | app, err := ablytest.NewSandbox(nil) 51 | assert.NoError(t, err) 52 | defer app.Close() 53 | opts := append(app.Options(), 54 | ably.WithUseTokenAuth(true), 55 | ) 56 | opts = append(opts, ably.WithClientID("some_client_id")) 57 | client, err := ably.NewREST(opts...) 58 | assert.NoError(t, err) 59 | t.Run("RSL1g1b", func(t *testing.T) { 60 | channel := client.Channels.Get("RSL1g1b") 61 | err := channel.PublishMultiple(context.Background(), []*ably.Message{ 62 | {Name: "some 1"}, 63 | {Name: "some 2"}, 64 | {Name: "some 3"}, 65 | }) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | var history []*ably.Message 70 | err = ablytest.AllPages(&history, channel.History()) 71 | assert.NoError(t, err) 72 | for _, m := range history { 73 | assert.Equal(t, "some_client_id", m.ClientID, 74 | "expected \"some_client_id\" got %s", m.ClientID) 75 | } 76 | }) 77 | t.Run("RSL1g2", func(t *testing.T) { 78 | channel := client.Channels.Get("RSL1g2") 79 | err := channel.PublishMultiple(context.Background(), []*ably.Message{ 80 | {Name: "1", ClientID: "some_client_id"}, 81 | {Name: "2", ClientID: "some_client_id"}, 82 | {Name: "3", ClientID: "some_client_id"}, 83 | }) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | var history []*ably.Message 88 | err = ablytest.AllPages(&history, channel.History()) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | for _, m := range history { 93 | assert.Equal(t, "some_client_id", m.ClientID, 94 | "expected \"some_client_id\" got %s", m.ClientID) 95 | } 96 | }) 97 | t.Run("RSL1g3", func(t *testing.T) { 98 | channel := client.Channels.Get("RSL1g3") 99 | err := channel.PublishMultiple(context.Background(), []*ably.Message{ 100 | {Name: "1", ClientID: "some_client_id"}, 101 | {Name: "2", ClientID: "other client"}, 102 | {Name: "3", ClientID: "some_client_id"}, 103 | }) 104 | assert.Error(t, err, 105 | "expected an error") 106 | }) 107 | } 108 | 109 | func TestHistory_RSL2_RSL2b3(t *testing.T) { 110 | 111 | for _, limit := range []int{2, 3, 20} { 112 | t.Run(fmt.Sprintf("limit=%d", limit), func(t *testing.T) { 113 | app, rest := ablytest.NewREST() 114 | defer app.Close() 115 | channel := rest.Channels.Get("persisted:test") 116 | 117 | fixtures := historyFixtures() 118 | channel.PublishMultiple(context.Background(), fixtures) 119 | 120 | err := ablytest.TestPagination( 121 | reverseMessages(fixtures), 122 | channel.History(ably.HistoryWithLimit(limit)), 123 | limit, 124 | ablytest.PaginationWithEqual(messagesEqual), 125 | ) 126 | assert.NoError(t, err) 127 | }) 128 | } 129 | } 130 | 131 | func TestHistory_Direction_RSL2b2(t *testing.T) { 132 | for _, c := range []struct { 133 | direction ably.Direction 134 | expected []*ably.Message 135 | }{ 136 | { 137 | direction: ably.Backwards, 138 | expected: reverseMessages(historyFixtures()), 139 | }, 140 | { 141 | direction: ably.Forwards, 142 | expected: historyFixtures(), 143 | }, 144 | } { 145 | c := c 146 | t.Run(fmt.Sprintf("direction=%v", c.direction), func(t *testing.T) { 147 | app, rest := ablytest.NewREST() 148 | defer app.Close() 149 | channel := rest.Channels.Get("persisted:test") 150 | 151 | fixtures := historyFixtures() 152 | channel.PublishMultiple(context.Background(), fixtures) 153 | 154 | expected := c.expected 155 | 156 | err := ablytest.TestPagination(expected, channel.History( 157 | ably.HistoryWithLimit(len(expected)), 158 | ably.HistoryWithDirection(c.direction), 159 | ), 100, ablytest.PaginationWithEqual(messagesEqual)) 160 | assert.NoError(t, err) 161 | }) 162 | } 163 | } 164 | 165 | func TestGetChannelLifecycleStatus_RSL8(t *testing.T) { 166 | ctx := context.Background() 167 | app, rest := ablytest.NewREST() 168 | defer app.Close() 169 | 170 | t.Run("Test Channel Status after Publish", func(t *testing.T) { 171 | channel := rest.Channels.Get("lifecycle:test") 172 | err := channel.Publish(ctx, "event", "data") 173 | assert.NoError(t, err) 174 | status, err := channel.Status(ctx) 175 | assert.NoError(t, err) 176 | assert.NotNil(t, status.ChannelId) 177 | assert.True(t, status.Status.IsActive) 178 | assert.Equal(t, "lifecycle:test", status.ChannelId) 179 | }) 180 | } 181 | 182 | func historyFixtures() []*ably.Message { 183 | var fixtures []*ably.Message 184 | for i := 0; i < 10; i++ { 185 | fixtures = append(fixtures, &ably.Message{Name: fmt.Sprintf("msg%d", i)}) 186 | } 187 | return fixtures 188 | } 189 | 190 | func reverseMessages(msgs []*ably.Message) []*ably.Message { 191 | var reversed []*ably.Message 192 | for i := len(msgs) - 1; i >= 0; i-- { 193 | reversed = append(reversed, msgs[i]) 194 | } 195 | return reversed 196 | } 197 | 198 | func messagesEqual(x, y interface{}) bool { 199 | mx, my := x.(*ably.Message), y.(*ably.Message) 200 | return mx.Name == my.Name && reflect.DeepEqual(mx.Data, my.Data) 201 | } 202 | -------------------------------------------------------------------------------- /ably/rest_client_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/ably/ably-go/ably" 12 | ) 13 | 14 | // When publishing a message to a channel, data can be either a single string or 15 | // a struct of type Message. This example shows the different ways to publish a message. 16 | func ExampleRESTChannel_Publish() { 17 | 18 | // Create a new REST client. 19 | client, err := ably.NewREST( 20 | ably.WithKey("ABLY_PRIVATE_KEY"), 21 | ably.WithClientID("Client A"), 22 | ) 23 | if err != nil { 24 | fmt.Println(err) 25 | return 26 | } 27 | 28 | // Initialise a new channel. 29 | channel := client.Channels.Get("chat") 30 | 31 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 32 | defer cancel() 33 | 34 | // Publish a string to the channel. 35 | if err := channel.Publish(ctx, "chat_message", "Hello, how are you?"); err != nil { 36 | fmt.Println(err) 37 | return 38 | } 39 | 40 | // Publish a single message to the channel. 41 | newChatMessage := ably.Message{ 42 | Name: "chat_message", 43 | Data: "Hello, how are you?", 44 | } 45 | 46 | if err := channel.Publish(ctx, "chat_message", newChatMessage); err != nil { 47 | fmt.Println(err) 48 | return 49 | } 50 | 51 | // Publish multiple messages in a single request. 52 | if err := channel.PublishMultiple(ctx, []*ably.Message{ 53 | {Name: "HelloEvent", Data: "Hello!"}, 54 | {Name: "ByeEvent", Data: "Bye!"}, 55 | }); err != nil { 56 | fmt.Println(err) 57 | return 58 | } 59 | 60 | // Publish a message on behalf of a different client. 61 | if err := channel.Publish(ctx, "temperature", "12.7", 62 | ably.PublishWithConnectionKey("connectionKeyOfAnotherClient"), 63 | ); err != nil { 64 | fmt.Println(err) 65 | return 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ably/token.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "fmt" 8 | "net/url" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | // TokenParams contains token params to be sent to ably to get auth token 14 | type TokenParams struct { 15 | // TTL is a requested time to live for the token in milliseconds. If the token request 16 | // is successful, the TTL of the returned token will be less than or equal 17 | // to this value depending on application settings and the attributes 18 | // of the issuing key. 19 | // The default is 60 minutes (RSA9e, TK2a). 20 | TTL int64 `json:"ttl,omitempty" codec:"ttl,omitempty"` 21 | 22 | // Capability represents encoded channel access rights associated with this Ably Token. 23 | // The capabilities value is a JSON-encoded representation of the resource paths and associated operations. 24 | // Read more about capabilities in the [capabilities docs]. 25 | // default '{"*":["*"]}' (RSA9f, TK2b) 26 | // 27 | // [capabilities docs]: https://ably.com/docs/core-features/authentication/#capabilities-explained 28 | Capability string `json:"capability,omitempty" codec:"capability,omitempty"` 29 | 30 | // ClientID is used for identifying this client when publishing messages or for presence purposes. 31 | // The clientId can be any non-empty string, except it cannot contain a *. This option is primarily intended 32 | // to be used in situations where the library is instantiated with a key. 33 | // Note that a clientId may also be implicit in a token used to instantiate the library. 34 | // An error is raised if a clientId specified here conflicts with the clientId implicit in the token. 35 | // Find out more about [identified clients] (TK2c). 36 | // 37 | // [identified clients]: https://ably.com/docs/core-features/authentication#identified-clients 38 | ClientID string `json:"clientId,omitempty" codec:"clientId,omitempty"` 39 | 40 | // Timestamp of the token request as milliseconds since the Unix epoch. 41 | // Timestamps, in conjunction with the nonce, are used to prevent requests from being replayed. 42 | // timestamp is a "one-time" value, and is valid in a request, but is not validly a member of 43 | // any default token params such as ClientOptions.defaultTokenParams (RSA9d, Tk2d). 44 | Timestamp int64 `json:"timestamp,omitempty" codec:"timestamp,omitempty"` 45 | } 46 | 47 | // Query encodes the params to query params value. If a field of params is 48 | // a zero-value, it's omitted. If params is zero-value, nil is returned. 49 | func (params *TokenParams) Query() url.Values { 50 | q := make(url.Values) 51 | if params == nil { 52 | return q 53 | } 54 | if params.TTL != 0 { 55 | q.Set("ttl", strconv.FormatInt(params.TTL, 10)) 56 | } 57 | if params.Capability != "" { 58 | q.Set("capability", params.Capability) 59 | } 60 | if params.ClientID != "" { 61 | q.Set("clientId", params.ClientID) 62 | } 63 | if params.Timestamp != 0 { 64 | q.Set("timestamp", strconv.FormatInt(params.Timestamp, 10)) 65 | } 66 | return q 67 | } 68 | 69 | // TokenRequest contains tokenparams with extra details, sent to ably for getting auth token 70 | type TokenRequest struct { 71 | TokenParams `codec:",inline"` 72 | 73 | // KeyName is the name of the key against which this request is made. 74 | // The key name is public, whereas the key secret is private (TE2). 75 | KeyName string `json:"keyName,omitempty" codec:"keyName,omitempty"` 76 | 77 | // Nonce is a cryptographically secure random string of at least 16 characters, 78 | // used to ensure the TokenRequest cannot be reused (TE2). 79 | Nonce string `json:"nonce,omitempty" codec:"nonce,omitempty"` 80 | 81 | // MAC is the Message Authentication Code for this request. 82 | MAC string `json:"mac,omitempty" codec:"mac,omitempty"` 83 | } 84 | 85 | func (TokenRequest) IsTokener() {} 86 | func (TokenRequest) isTokener() {} 87 | 88 | func (req *TokenRequest) sign(secret []byte) { 89 | mac := hmac.New(sha256.New, secret) 90 | fmt.Fprintln(mac, req.KeyName) 91 | fmt.Fprintln(mac, req.TTL) 92 | fmt.Fprintln(mac, req.Capability) 93 | fmt.Fprintln(mac, req.ClientID) 94 | fmt.Fprintln(mac, req.Timestamp) 95 | fmt.Fprintln(mac, req.Nonce) 96 | req.MAC = base64.StdEncoding.EncodeToString(mac.Sum(nil)) 97 | } 98 | 99 | // TokenDetails contains an Ably Token and its associated metadata. 100 | type TokenDetails struct { 101 | 102 | // Token is the ably Token itself (TD2). 103 | // A typical Ably Token string appears with the form xVLyHw.A-pwh7wicf3afTfgiw4k2Ku33kcnSA7z6y8FjuYpe3QaNRTEo4. 104 | Token string `json:"token,omitempty" codec:"token,omitempty"` 105 | 106 | // KeyName is a string part of ABLY_KEY before : 107 | KeyName string `json:"keyName,omitempty" codec:"keyName,omitempty"` 108 | 109 | // Expires is the timestamp at which this token expires as milliseconds since the Unix epoch (TD3). 110 | Expires int64 `json:"expires,omitempty" codec:"expires,omitempty"` 111 | 112 | // ClientID, if any, bound to this Ably Token. If a client ID is included, then the Ably Token authenticates 113 | // its bearer as that client ID, and the Ably Token may only be used to perform operations on behalf 114 | // of that client ID. The client is then considered to be an identified client (TD6). 115 | ClientID string `json:"clientId,omitempty" codec:"clientId,omitempty"` 116 | 117 | // Issued is the timestamp at which this token was issued as milliseconds since the Unix epoch. 118 | Issued int64 `json:"issued,omitempty" codec:"issued,omitempty"` 119 | 120 | // Capability is the capabilities associated with this Ably Token. 121 | // The capabilities value is a JSON-encoded representation of the resource paths and associated operations. 122 | // Read more about capabilities in the [capabilities docs] (TD5). 123 | // 124 | // [capabilities docs]: https://ably.com/docs/core-features/authentication/#capabilities-explained 125 | Capability string `json:"capability,omitempty" codec:"capability,omitempty"` 126 | } 127 | 128 | func (TokenDetails) IsTokener() {} 129 | func (TokenDetails) isTokener() {} 130 | 131 | func (tok *TokenDetails) expired(now time.Time) bool { 132 | return tok.Expires != 0 && tok.Expires <= unixMilli(now) 133 | } 134 | 135 | func (tok *TokenDetails) IssueTime() time.Time { 136 | return time.Unix(tok.Issued/1000, tok.Issued%1000*int64(time.Millisecond)) 137 | } 138 | 139 | func (tok *TokenDetails) ExpireTime() time.Time { 140 | return time.Unix(tok.Expires/1000, tok.Expires%1000*int64(time.Millisecond)) 141 | } 142 | 143 | func newTokenDetails(token string) *TokenDetails { 144 | return &TokenDetails{ 145 | Token: token, 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ably/websocket.go: -------------------------------------------------------------------------------- 1 | package ably 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | 11 | "github.com/ably/ably-go/ably/internal/ablyutil" 12 | "nhooyr.io/websocket" 13 | ) 14 | 15 | type proto int 16 | 17 | const ( 18 | jsonProto proto = iota 19 | msgpackProto 20 | ) 21 | 22 | type websocketConn struct { 23 | conn *websocket.Conn 24 | proto proto 25 | } 26 | 27 | type websocketErr struct { 28 | err error 29 | resp *http.Response 30 | } 31 | 32 | // websocketErr implements the builtin error interface. 33 | func (e *websocketErr) Error() string { 34 | return e.err.Error() 35 | } 36 | 37 | // Unwrap implements the implicit interface that errors.Unwrap understands. 38 | func (e *websocketErr) Unwrap() error { 39 | return e.err 40 | } 41 | 42 | func (ws *websocketConn) Send(msg *protocolMessage) error { 43 | switch ws.proto { 44 | case jsonProto: 45 | p, err := json.Marshal(msg) 46 | if err != nil { 47 | return err 48 | } 49 | return ws.conn.Write(context.Background(), websocket.MessageText, p) 50 | case msgpackProto: 51 | p, err := ablyutil.MarshalMsgpack(msg) 52 | if err != nil { 53 | return err 54 | } 55 | return ws.conn.Write(context.Background(), websocket.MessageBinary, p) 56 | } 57 | return nil 58 | } 59 | 60 | func (ws *websocketConn) Receive(deadline time.Time) (*protocolMessage, error) { 61 | msg := &protocolMessage{} 62 | var ( 63 | ctx context.Context 64 | cancel context.CancelFunc 65 | ) 66 | if deadline.IsZero() { 67 | ctx = context.Background() 68 | } else { 69 | ctx, cancel = context.WithDeadline(context.Background(), deadline) 70 | defer cancel() 71 | } 72 | _, data, err := ws.conn.Read(ctx) 73 | if err != nil { 74 | return nil, err 75 | } 76 | switch ws.proto { 77 | case jsonProto: 78 | err := json.Unmarshal(data, msg) 79 | if err != nil { 80 | return nil, err 81 | } 82 | case msgpackProto: 83 | err := ablyutil.UnmarshalMsgpack(data, msg) 84 | if err != nil { 85 | return nil, err 86 | } 87 | } 88 | return msg, nil 89 | } 90 | 91 | func (ws *websocketConn) Close() error { 92 | return ws.conn.Close(websocket.StatusNormalClosure, "") 93 | } 94 | 95 | func dialWebsocket(proto string, u *url.URL, timeout time.Duration, agents map[string]string) (*websocketConn, error) { 96 | ws := &websocketConn{} 97 | switch proto { 98 | case "application/json": 99 | ws.proto = jsonProto 100 | case "application/x-msgpack": 101 | ws.proto = msgpackProto 102 | default: 103 | return nil, errors.New(`invalid protocol "` + proto + `"`) 104 | } 105 | // Starts a raw websocket connection with server 106 | conn, resp, err := dialWebsocketTimeout(u.String(), "https://"+u.Host, timeout, agents) 107 | if err != nil { 108 | return nil, &websocketErr{err: err, resp: resp} 109 | } 110 | ws.conn = conn 111 | return ws, nil 112 | } 113 | 114 | // dialWebsocketTimeout dials the websocket with a timeout. 115 | func dialWebsocketTimeout(uri, origin string, timeout time.Duration, agents map[string]string) (*websocket.Conn, *http.Response, error) { 116 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 117 | defer cancel() 118 | 119 | var ops websocket.DialOptions 120 | ops.HTTPHeader = make(http.Header) 121 | ops.HTTPHeader.Add(ablyAgentHeader, ablyAgentIdentifier(agents)) 122 | 123 | c, resp, err := websocket.Dial(ctx, uri, &ops) 124 | 125 | if err != nil { 126 | return nil, resp, err 127 | } 128 | 129 | return c, resp, nil 130 | } 131 | 132 | func unwrapConn(c conn) conn { 133 | u, ok := c.(interface { 134 | Unwrap() conn 135 | }) 136 | if !ok { 137 | return c 138 | } 139 | return unwrapConn(u.Unwrap()) 140 | } 141 | 142 | func extractHttpResponseFromError(err error) *http.Response { 143 | wsErr, ok := err.(*websocketErr) 144 | if ok { 145 | return wsErr.resp 146 | } 147 | return nil 148 | } 149 | 150 | func setConnectionReadLimit(c conn, readLimit int64) error { 151 | unwrappedConn := unwrapConn(c) 152 | websocketConn, ok := unwrappedConn.(*websocketConn) 153 | if !ok { 154 | return errors.New("cannot set readlimit for connection, connection does not use nhooyr.io/websocket") 155 | } 156 | websocketConn.conn.SetReadLimit(readLimit) 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /ably/websocket_internal_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ably 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "log" 13 | "net/http" 14 | "net/http/httptest" 15 | "net/url" 16 | "strings" 17 | "testing" 18 | "time" 19 | 20 | "github.com/stretchr/testify/assert" 21 | "github.com/ugorji/go/codec" 22 | "nhooyr.io/websocket" 23 | ) 24 | 25 | var timeout = time.Second * 30 26 | 27 | // handleWebsocketHandshake is a handler that can handle a websocket handshake and connection from a client. 28 | func handleWebsocketHandshake(w http.ResponseWriter, r *http.Request) { 29 | conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{}) 30 | if err != nil { 31 | log.Printf("test server: error accepting websocket handshake: %+v\n", err) 32 | } 33 | defer conn.Close(websocket.StatusNormalClosure, "") 34 | } 35 | 36 | // handleWebsocketMessages is a handler that receives a protocol message over a websocket connection then 37 | // responds by sending a protocol message back to the client including the message type and original message. 38 | func handleWebsocketMessage(w http.ResponseWriter, r *http.Request) { 39 | conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{}) 40 | if err != nil { 41 | log.Printf("test server: error accepting websocket handshake: %+v\n", err) 42 | } 43 | defer conn.Close(websocket.StatusInternalError, "connection closed") 44 | 45 | ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) 46 | defer cancel() 47 | 48 | // Read message received from client. 49 | msgType, received, err := conn.Read(ctx) 50 | if err != nil { 51 | log.Printf("test server: error reading received message: %+v\n", err) 52 | } 53 | 54 | // Reply to message with the type of message received and the message string. 55 | reply := protocolMessage{ 56 | Action: actionMessage, 57 | Messages: []*Message{{Name: msgType.String(), Data: string(received)}}, 58 | } 59 | 60 | var payloadType websocket.MessageType 61 | var payload []byte 62 | 63 | switch msgType.String() { 64 | case "MessageText": 65 | payloadType = websocket.MessageText 66 | 67 | payload, err = json.Marshal(reply) 68 | if err != nil { 69 | log.Printf("test server: error marshalling json: %+v\n", err) 70 | } 71 | 72 | case "MessageBinary": 73 | payloadType = websocket.MessageBinary 74 | 75 | var handle codec.MsgpackHandle 76 | var buf bytes.Buffer 77 | enc := codec.NewEncoder(&buf, &handle) 78 | if err := enc.Encode(reply); err != nil { 79 | log.Printf("test server: error encoding msg: %+v\n", err) 80 | } 81 | payload = buf.Bytes() 82 | } 83 | 84 | if err := conn.Write(ctx, payloadType, payload); err != nil { 85 | log.Println("test server: error sending message\n", err) 86 | } 87 | } 88 | 89 | func TestWebsocketDial(t *testing.T) { 90 | // Create test server with a handler that will accept a websocket handshake. 91 | ts := httptest.NewServer(http.HandlerFunc(handleWebsocketHandshake)) 92 | defer ts.Close() 93 | 94 | // Convert http:// to ws:// and parse the test server url 95 | websocketUrl := fmt.Sprintf("ws%s", strings.TrimPrefix(ts.URL, "http")) 96 | testServerURL, _ := url.Parse(websocketUrl) 97 | 98 | tests := map[string]struct { 99 | dialProtocol string 100 | expectedErr error 101 | expectedProto proto 102 | }{ 103 | "Can dial for protocol application/json": { 104 | dialProtocol: "application/json", 105 | expectedErr: nil, 106 | expectedProto: jsonProto, 107 | }, 108 | "Can dial for protocol application/x-msgpack": { 109 | dialProtocol: "application/x-msgpack", 110 | expectedErr: nil, 111 | expectedProto: msgpackProto, 112 | }, 113 | "Can handle an error when dialing for an invalid protocol": { 114 | dialProtocol: "aProtocol", 115 | expectedErr: errors.New(`invalid protocol "aProtocol"`), 116 | }, 117 | } 118 | 119 | for testName, test := range tests { 120 | t.Run(testName, func(t *testing.T) { 121 | 122 | result, err := dialWebsocket(test.dialProtocol, testServerURL, timeout, nil) 123 | assert.Equal(t, test.expectedErr, err) 124 | 125 | if result != nil { 126 | assert.NotNil(t, result.conn) 127 | assert.Equal(t, test.expectedProto, result.proto) 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func TestWebsocketSendAndReceive(t *testing.T) { 134 | // Create test server with a handler that can receive a message. 135 | ts := httptest.NewServer(http.HandlerFunc(handleWebsocketMessage)) 136 | defer ts.Close() 137 | 138 | // Convert http:// to ws:// and parse the test server url 139 | websocketUrl := fmt.Sprintf("ws%s", strings.TrimPrefix(ts.URL, "http")) 140 | testServerURL, _ := url.Parse(websocketUrl) 141 | 142 | tests := map[string]struct { 143 | dialProtocol string 144 | expectedMessageType string 145 | expectedErr error 146 | }{ 147 | "Can send and receive a message using protocol application/json": { 148 | dialProtocol: "application/json", 149 | expectedMessageType: "MessageText", 150 | expectedErr: nil, 151 | }, 152 | "Can send and receive a message using protocol application/x-msgpack": { 153 | dialProtocol: "application/x-msgpack", 154 | expectedMessageType: "MessageBinary", 155 | expectedErr: nil, 156 | }, 157 | } 158 | 159 | for testName, test := range tests { 160 | t.Run(testName, func(t *testing.T) { 161 | 162 | ws, err := dialWebsocket(test.dialProtocol, testServerURL, timeout, nil) 163 | assert.NoError(t, err) 164 | msg := protocolMessage{ 165 | Messages: []*Message{{Name: "temperature", Data: "22.7"}}, 166 | } 167 | 168 | ws.Send(&msg) 169 | result, err := ws.Receive(time.Now().Add(timeout)) 170 | 171 | assert.Equal(t, test.expectedErr, err) 172 | assert.Equal(t, test.expectedMessageType, result.Messages[0].Name) 173 | assert.Equal(t, 1, len(result.Messages)) 174 | assert.Equal(t, actionMessage, result.Action) 175 | assert.Contains(t, result.Messages[0].Data, "temperature") 176 | assert.Contains(t, result.Messages[0].Data, "22.7") 177 | }) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /ablytest/ablytest.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "reflect" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "github.com/ably/ably-go/ably" 15 | ) 16 | 17 | var Timeout = 30 * time.Second 18 | var NoBinaryProtocol bool 19 | var DefaultLogLevel = ably.LogNone 20 | var Endpoint = "nonprod:sandbox" 21 | 22 | func nonil(err ...error) error { 23 | for _, err := range err { 24 | if err != nil { 25 | return err 26 | } 27 | } 28 | return nil 29 | } 30 | 31 | func init() { 32 | if s := os.Getenv("ABLY_TIMEOUT"); s != "" { 33 | if t, err := time.ParseDuration(s); err == nil { 34 | Timeout = t 35 | } 36 | } 37 | if os.Getenv("ABLY_PROTOCOL") == "application/json" { 38 | NoBinaryProtocol = true 39 | } 40 | if n, err := strconv.Atoi(os.Getenv("ABLY_LOGLEVEL")); err == nil { 41 | DefaultLogLevel = ably.LogLevel(n) 42 | } 43 | if s := os.Getenv("ABLY_ENDPOINT"); s != "" { 44 | Endpoint = s 45 | } 46 | } 47 | 48 | func MergeOptions(opts ...[]ably.ClientOption) []ably.ClientOption { 49 | var merged []ably.ClientOption 50 | for _, opt := range opts { 51 | merged = append(merged, opt...) 52 | } 53 | return merged 54 | } 55 | 56 | func encode(typ string, in interface{}) ([]byte, error) { 57 | switch typ { 58 | case "application/json": 59 | return json.Marshal(in) 60 | case "application/x-msgpack": 61 | return marshalMsgpack(in) 62 | case "text/plain": 63 | return []byte(fmt.Sprintf("%v", in)), nil 64 | default: 65 | return nil, fmt.Errorf("encoding error: unrecognized Content-Type: %q", typ) 66 | } 67 | } 68 | 69 | var ClientOptionsInspector struct { 70 | UseBinaryProtocol func([]ably.ClientOption) bool 71 | HTTPClient func([]ably.ClientOption) *http.Client 72 | } 73 | 74 | func protocol(opts []ably.ClientOption) string { 75 | if ClientOptionsInspector.UseBinaryProtocol(opts) { 76 | return "application/x-msgpack" 77 | } 78 | return "application/json" 79 | } 80 | 81 | func Contains(s, sub interface{}) bool { 82 | return reflectContains(reflect.ValueOf(s), reflect.ValueOf(sub)) 83 | } 84 | 85 | func reflectContains(s, sub reflect.Value) bool { 86 | for i := 0; i+sub.Len() <= s.Len(); i++ { 87 | if reflect.DeepEqual( 88 | s.Slice(i, i+sub.Len()).Interface(), 89 | sub.Interface(), 90 | ) { 91 | return true 92 | } 93 | } 94 | return false 95 | } 96 | 97 | type MessageChannel chan *ably.Message 98 | 99 | func (ch MessageChannel) Receive(m *ably.Message) { 100 | ch <- m 101 | } 102 | 103 | func ReceiveMessages(channel *ably.RealtimeChannel, name string) (messages <-chan *ably.Message, unsubscribe func(), err error) { 104 | ch := make(MessageChannel, 100) 105 | if name == "" { 106 | unsubscribe, err = channel.SubscribeAll(context.Background(), ch.Receive) 107 | } else { 108 | unsubscribe, err = channel.Subscribe(context.Background(), name, ch.Receive) 109 | } 110 | return ch, unsubscribe, err 111 | } 112 | 113 | type PresenceChannel chan *ably.PresenceMessage 114 | 115 | func (ch PresenceChannel) Receive(m *ably.PresenceMessage) { 116 | ch <- m 117 | } 118 | 119 | func ReceivePresenceMessages(channel *ably.RealtimeChannel, action *ably.PresenceAction) (messages <-chan *ably.PresenceMessage, unsubscribe func(), err error) { 120 | ch := make(PresenceChannel, 100) 121 | if action == nil { 122 | unsubscribe, err = channel.Presence.SubscribeAll(context.Background(), ch.Receive) 123 | } else { 124 | unsubscribe, err = channel.Presence.Subscribe(context.Background(), *action, ch.Receive) 125 | } 126 | return ch, unsubscribe, err 127 | } 128 | 129 | type AfterCall struct { 130 | Ctx context.Context 131 | D time.Duration 132 | Deadline time.Time 133 | Time chan<- time.Time 134 | triggered *bool 135 | mtx *sync.Mutex 136 | } 137 | 138 | func (c AfterCall) Fire() { 139 | c.Time <- c.Deadline 140 | } 141 | 142 | func (a AfterCall) setTriggered(value bool) { 143 | a.mtx.Lock() 144 | defer a.mtx.Unlock() 145 | *a.triggered = value 146 | } 147 | 148 | func (a AfterCall) IsTriggered() bool { 149 | a.mtx.Lock() 150 | defer a.mtx.Unlock() 151 | return *a.triggered 152 | } 153 | 154 | // TimeFuncs returns time functions to be passed as options. 155 | // 156 | // Now returns a stable time that is only updated with the times that the 157 | // returned After produces. 158 | // 159 | // After forwards calls to the given channel. The receiver is in charge of 160 | // sending the resulting time to the AfterCall.Time channel. 161 | func TimeFuncs(afterCalls chan<- AfterCall) ( 162 | now func() time.Time, 163 | after func(context.Context, time.Duration) <-chan time.Time, 164 | ) { 165 | var mtx sync.Mutex 166 | currentTime := time.Now() 167 | now = func() time.Time { 168 | mtx.Lock() 169 | defer mtx.Unlock() 170 | return currentTime 171 | } 172 | 173 | after = func(ctx context.Context, d time.Duration) <-chan time.Time { 174 | ch := make(chan time.Time, 1) 175 | 176 | timeUpdate := make(chan time.Time, 1) 177 | go func() { 178 | mtx.Lock() 179 | t := currentTime 180 | mtx.Unlock() 181 | afterCall := AfterCall{Ctx: ctx, D: d, Deadline: t.Add(d), Time: timeUpdate, triggered: new(bool), mtx: &sync.Mutex{}} 182 | select { 183 | case afterCalls <- afterCall: 184 | case <-ctx.Done(): 185 | // This allows tests to ignore a call if they expect the timer to 186 | // be cancelled. 187 | return 188 | } 189 | 190 | select { 191 | case <-ctx.Done(): 192 | close(ch) 193 | 194 | case t, ok := <-timeUpdate: 195 | if !ok { 196 | close(ch) 197 | return 198 | } 199 | mtx.Lock() 200 | currentTime = t 201 | mtx.Unlock() 202 | ch <- t 203 | afterCall.setTriggered(true) 204 | } 205 | }() 206 | 207 | return ch 208 | } 209 | 210 | return now, after 211 | } 212 | -------------------------------------------------------------------------------- /ablytest/cryptodata.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | type CryptoData struct { 12 | Algorithm string `json:"algorithm"` 13 | Mode string `json:"mode"` 14 | KeyLen int `json:"keylength"` 15 | Key string `json:"key"` 16 | IV string `json:"iv"` 17 | Items []struct { 18 | Encoded json.RawMessage `json:"encoded"` 19 | Encrypted json.RawMessage `json:"encrypted"` 20 | } `json:"items"` 21 | } 22 | 23 | func LoadCryptoData(rel string) (*CryptoData, []byte, []byte, error) { 24 | data := &CryptoData{} 25 | f, err := os.Open(filepath.Join("..", "common", filepath.FromSlash(rel))) 26 | if err != nil { 27 | return nil, nil, nil, errors.New("missing common subrepo - ensure git submodules are initialized") 28 | } 29 | err = json.NewDecoder(f).Decode(data) 30 | f.Close() 31 | if err != nil { 32 | return nil, nil, nil, errors.New("unable to unmarshal test cases: " + err.Error()) 33 | } 34 | key, err := base64.StdEncoding.DecodeString(data.Key) 35 | if err != nil { 36 | return nil, nil, nil, errors.New("unable to unbase64 key" + err.Error()) 37 | } 38 | iv, err := base64.StdEncoding.DecodeString(data.IV) 39 | if err != nil { 40 | return nil, nil, nil, errors.New("unable to unbase64 IV" + err.Error()) 41 | } 42 | return data, key, iv, nil 43 | } 44 | -------------------------------------------------------------------------------- /ablytest/fmt.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | // FmtFunc is a non-failing function analogous to fmt.Printf. 9 | type FmtFunc func(format string, args ...interface{}) 10 | 11 | // Wrap wraps a FmtFunc in another. The wrapper FmtFunc first uses the fixed 12 | // format and args, plus the format it's called with, to create a new format 13 | // string by calling fmt.Sprintf. Then, it calls the wrapped FmtFunc with this 14 | // format string and the args the wrapper is called with. 15 | // 16 | // It's useful to have some fixed context on everything that is formatted with 17 | // a FmtFunc, e.g. test scope context. 18 | func (f FmtFunc) Wrap(t *testing.T, format string, args ...interface{}) FmtFunc { 19 | return func(wrappedFormat string, wrappedArgs ...interface{}) { 20 | if t != nil { 21 | t.Helper() 22 | } 23 | f(fmt.Sprintf(format, append(args, wrappedFormat)...), wrappedArgs...) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ablytest/fmt_test.go: -------------------------------------------------------------------------------- 1 | //go:build !integration 2 | // +build !integration 3 | 4 | package ablytest_test 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/ably/ably-go/ablytest" 10 | ) 11 | 12 | func ExampleFmtFunc_Wrap() { 13 | myPrintln := ablytest.FmtFunc(func(format string, args ...interface{}) { 14 | fmt.Println(fmt.Sprintf(format, args...)) 15 | }) 16 | 17 | id := 42 18 | wrapped := myPrintln.Wrap(nil, "for ID %d: %s", id) 19 | 20 | wrapped("everything's OK") 21 | 22 | errMsg := "all hell broke loose" 23 | wrapped("something failed: %s", errMsg) 24 | 25 | // Output: 26 | // for ID 42: everything's OK 27 | // for ID 42: something failed: all hell broke loose 28 | } 29 | -------------------------------------------------------------------------------- /ablytest/logger.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ably/ably-go/ably" 7 | ) 8 | 9 | type LogMessage struct { 10 | Level ably.LogLevel 11 | Message string 12 | } 13 | 14 | func NewLogger(messages chan<- LogMessage) ably.Logger { 15 | return testLogger{messages: messages} 16 | } 17 | 18 | type testLogger struct { 19 | messages chan<- LogMessage 20 | } 21 | 22 | func (l testLogger) Printf(level ably.LogLevel, format string, v ...interface{}) { 23 | l.messages <- LogMessage{ 24 | Level: level, 25 | Message: fmt.Sprintf(format, v...), 26 | } 27 | } 28 | 29 | var DiscardLogger ably.Logger = discardLogger{} 30 | 31 | type discardLogger struct{} 32 | 33 | func (discardLogger) Printf(level ably.LogLevel, format string, v ...interface{}) {} 34 | -------------------------------------------------------------------------------- /ablytest/msgpack.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | 7 | "github.com/ugorji/go/codec" 8 | ) 9 | 10 | var handle codec.MsgpackHandle 11 | 12 | func init() { 13 | handle.Raw = true 14 | handle.WriteExt = true 15 | handle.RawToString = true 16 | } 17 | 18 | // marshalMsgpack returns msgpack encoding of v 19 | func marshalMsgpack(v interface{}) ([]byte, error) { 20 | var buf bytes.Buffer 21 | err := encodeMsg(&buf, v) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return buf.Bytes(), nil 26 | } 27 | 28 | // encodeMsg encodes v into msgpack format and writes the output to w. 29 | func encodeMsg(w io.Writer, v interface{}) error { 30 | enc := codec.NewEncoder(w, &handle) 31 | return enc.Encode(v) 32 | } 33 | -------------------------------------------------------------------------------- /ablytest/proxies.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | 13 | "github.com/ably/ably-go/ably" 14 | ) 15 | 16 | var hopHeaders = map[string]struct{}{ 17 | "Connection": {}, 18 | "Keep-Alive": {}, 19 | "Proxy-Authenticate": {}, 20 | "Proxy-Authorization": {}, 21 | "Te": {}, 22 | "Trailers": {}, 23 | "Transfer-Encoding": {}, 24 | "Upgrade": {}, 25 | } 26 | 27 | func NewTokenParams(query url.Values) *ably.TokenParams { 28 | params := &ably.TokenParams{} 29 | if n, err := strconv.ParseInt(query.Get("ttl"), 10, 64); err == nil { 30 | params.TTL = n 31 | } 32 | if s := query.Get("capability"); s != "" { 33 | params.Capability = s 34 | } 35 | if s := query.Get("clientId"); s != "" { 36 | params.ClientID = s 37 | } 38 | if n, err := strconv.ParseInt(query.Get("timestamp"), 10, 64); err == nil { 39 | params.Timestamp = n 40 | } 41 | return params 42 | } 43 | 44 | func Query(req *http.Request) (url.Values, error) { 45 | switch req.Method { 46 | case "GET": 47 | return req.URL.Query(), nil 48 | case "POST": 49 | p, err := io.ReadAll(req.Body) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return url.ParseQuery(string(p)) 54 | default: 55 | return nil, errors.New(http.StatusText(405)) 56 | } 57 | } 58 | 59 | func MustQuery(req *http.Request) url.Values { 60 | query, err := Query(req) 61 | if err != nil { 62 | panic(err) 63 | } 64 | return query 65 | } 66 | 67 | // AuthReverseProxy serves token requests by reverse proxying them to 68 | // the Ably servers. Use URL method for creating values for AuthURL 69 | // option and Callback method - for AuthCallback ones. 70 | type AuthReverseProxy struct { 71 | TokenQueue []*ably.TokenDetails // when non-nil pops the token from the queue instead querying Ably servers 72 | Listener net.Listener // listener which accepts token request connections 73 | 74 | auth *ably.Auth 75 | proto string 76 | } 77 | 78 | // NewAuthReverseProxy creates new auth reverse proxy. The given opts 79 | // are used to create a Auth client, used to reverse proxying token requests. 80 | func NewAuthReverseProxy(opts ...ably.ClientOption) (*AuthReverseProxy, error) { 81 | client, err := ably.NewREST(append(opts, 82 | ably.WithUseTokenAuth(true), 83 | )...) 84 | if err != nil { 85 | return nil, err 86 | } 87 | lis, err := net.Listen("tcp4", ":0") 88 | if err != nil { 89 | return nil, err 90 | } 91 | srv := &AuthReverseProxy{ 92 | Listener: lis, 93 | auth: client.Auth, 94 | proto: protocol(opts), 95 | } 96 | go http.Serve(lis, srv) 97 | return srv, nil 98 | } 99 | 100 | // MustAuthReverseProxy panics when creating the proxy fails. 101 | func MustAuthReverseProxy(opts ...ably.ClientOption) *AuthReverseProxy { 102 | srv, err := NewAuthReverseProxy(opts...) 103 | if err != nil { 104 | panic(err) 105 | } 106 | return srv 107 | } 108 | 109 | // URL gives new AuthURL for the requested responseType. Available response 110 | // types are: 111 | // 112 | // - "token", which responds with (ably.TokenDetails).Token as a string 113 | // - "details", which responds with ably.TokenDetails 114 | // - "request", which responds with ably.TokenRequest 115 | func (srv *AuthReverseProxy) URL(responseType string) string { 116 | return "http://" + srv.Listener.Addr().String() + "/" + responseType 117 | } 118 | 119 | // Callback gives new AuthCallback. Available response types are the same 120 | // as for URL method. 121 | func (srv *AuthReverseProxy) Callback(responseType string) func(context.Context, ably.TokenParams) (ably.Tokener, error) { 122 | return func(ctx context.Context, params ably.TokenParams) (ably.Tokener, error) { 123 | token, _, err := srv.handleAuth(ctx, responseType, params) 124 | return token, err 125 | } 126 | } 127 | 128 | // Close makes the proxy server stop accepting connections. 129 | func (srv *AuthReverseProxy) Close() error { 130 | return srv.Listener.Close() 131 | } 132 | 133 | // ServeHTTP implements the http.Handler interface. 134 | func (srv *AuthReverseProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { 135 | query, err := Query(req) 136 | if err != nil { 137 | http.Error(w, err.Error(), 400) 138 | return 139 | } 140 | token, contentType, err := srv.handleAuth(req.Context(), req.URL.Path[1:], *NewTokenParams(query)) 141 | if err != nil { 142 | http.Error(w, err.Error(), 400) 143 | return 144 | } 145 | p, err := encode(contentType, token) 146 | if err != nil { 147 | http.Error(w, err.Error(), 400) 148 | return 149 | } 150 | for k, v := range req.Header { 151 | if _, ok := hopHeaders[k]; !ok { 152 | w.Header()[k] = v 153 | } 154 | } 155 | w.Header().Set("Content-Type", contentType) 156 | w.Header().Set("Content-Length", strconv.Itoa(len(p))) 157 | w.WriteHeader(200) 158 | if _, err = io.Copy(w, bytes.NewReader(p)); err != nil { 159 | panic(err) 160 | } 161 | } 162 | 163 | func (srv *AuthReverseProxy) handleAuth(ctx context.Context, responseType string, params ably.TokenParams) (token ably.Tokener, typ string, err error) { 164 | switch responseType { 165 | case "token", "details": 166 | var tok *ably.TokenDetails 167 | if len(srv.TokenQueue) != 0 { 168 | tok, srv.TokenQueue = srv.TokenQueue[0], srv.TokenQueue[1:] 169 | } else { 170 | tok, err = srv.auth.Authorize(ctx, ¶ms) 171 | if err != nil { 172 | return nil, "", err 173 | } 174 | } 175 | if responseType == "token" { 176 | return ably.TokenString(tok.Token), "text/plain", nil 177 | } 178 | return tok, srv.proto, nil 179 | case "request": 180 | tokReq, err := srv.auth.CreateTokenRequest(¶ms) 181 | if err != nil { 182 | return nil, "", err 183 | } 184 | return tokReq, srv.proto, nil 185 | default: 186 | return nil, "", errors.New("unexpected token value type: " + typ) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /ablytest/recorders.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "sync" 9 | "sync/atomic" 10 | 11 | "github.com/ably/ably-go/ably" 12 | ) 13 | 14 | // RoundTripRecorder is a http.Transport wrapper which records 15 | // HTTP request/response pairs. 16 | type RoundTripRecorder struct { 17 | *http.Transport 18 | 19 | mtx sync.Mutex 20 | reqs []*http.Request 21 | resps []*http.Response 22 | stopped int32 23 | } 24 | 25 | func NewHttpRecorder() (*RoundTripRecorder, []ably.ClientOption) { 26 | rec := &RoundTripRecorder{} 27 | httpClient := &http.Client{Transport: &http.Transport{}} 28 | httpClient.Transport = rec.Hijack(httpClient.Transport) 29 | return rec, []ably.ClientOption{ably.WithHTTPClient(httpClient)} 30 | } 31 | 32 | var _ http.RoundTripper = (*RoundTripRecorder)(nil) 33 | 34 | // Len gives number of recorded request/response pairs. 35 | // 36 | // It is save to call Len() before calling Stop(). 37 | func (rec *RoundTripRecorder) Len() int { 38 | rec.mtx.Lock() 39 | defer rec.mtx.Unlock() 40 | return len(rec.reqs) 41 | } 42 | 43 | // Request gives nth recorded http.Request. 44 | func (rec *RoundTripRecorder) Request(n int) *http.Request { 45 | rec.mtx.Lock() 46 | defer rec.mtx.Unlock() 47 | return rec.reqs[n] 48 | } 49 | 50 | // Response gives nth recorded http.Response. 51 | func (rec *RoundTripRecorder) Response(n int) *http.Response { 52 | rec.mtx.Lock() 53 | defer rec.mtx.Unlock() 54 | return rec.resps[n] 55 | } 56 | 57 | // Requests gives all HTTP requests in order they were recorded. 58 | func (rec *RoundTripRecorder) Requests() []*http.Request { 59 | rec.mtx.Lock() 60 | defer rec.mtx.Unlock() 61 | reqs := make([]*http.Request, len(rec.reqs)) 62 | copy(reqs, rec.reqs) 63 | return reqs 64 | } 65 | 66 | // Responses gives all HTTP responses in order they were recorded. 67 | func (rec *RoundTripRecorder) Responses() []*http.Response { 68 | rec.mtx.Lock() 69 | defer rec.mtx.Unlock() 70 | resps := make([]*http.Response, len(rec.resps)) 71 | copy(resps, rec.resps) 72 | return resps 73 | } 74 | 75 | // RoundTrip implements the http.RoundTripper interface. 76 | func (rec *RoundTripRecorder) RoundTrip(req *http.Request) (*http.Response, error) { 77 | if atomic.LoadInt32(&rec.stopped) == 0 { 78 | return rec.roundTrip(req) 79 | } 80 | return rec.Transport.RoundTrip(req) 81 | } 82 | 83 | // Stop makes the recorder stop recording new requests/responses. 84 | func (rec *RoundTripRecorder) Stop() { 85 | atomic.StoreInt32(&rec.stopped, 1) 86 | } 87 | 88 | // Hijack injects http.Transport into the wrapper. 89 | func (rec *RoundTripRecorder) Hijack(rt http.RoundTripper) http.RoundTripper { 90 | if tr, ok := rt.(*http.Transport); ok { 91 | rec.Transport = tr 92 | } 93 | return rec 94 | } 95 | 96 | // Reset resets the recorder requests and responses. 97 | func (rec *RoundTripRecorder) Reset() { 98 | rec.mtx.Lock() 99 | rec.reqs = nil 100 | rec.resps = nil 101 | rec.mtx.Unlock() 102 | } 103 | 104 | func (rec *RoundTripRecorder) roundTrip(req *http.Request) (*http.Response, error) { 105 | var buf bytes.Buffer 106 | if req.Body != nil { 107 | req.Body = io.NopCloser(io.TeeReader(req.Body, &buf)) 108 | } 109 | resp, err := rec.Transport.RoundTrip(req) 110 | req.Body = body(buf.Bytes()) 111 | buf.Reset() 112 | if resp != nil && resp.Body != nil { 113 | _, e := io.Copy(&buf, resp.Body) 114 | err = nonil(err, e, resp.Body.Close()) 115 | resp.Body = body(buf.Bytes()) 116 | } 117 | rec.mtx.Lock() 118 | respCopy := *resp 119 | respCopy.Body = body(buf.Bytes()) 120 | rec.reqs = append(rec.reqs, req) 121 | rec.resps = append(rec.resps, &respCopy) 122 | rec.mtx.Unlock() 123 | return resp, err 124 | } 125 | 126 | type ConnStatesRecorder struct { 127 | mtx sync.Mutex 128 | states []ably.ConnectionState 129 | } 130 | 131 | func (cs *ConnStatesRecorder) append(state ably.ConnectionState) { 132 | cs.mtx.Lock() 133 | defer cs.mtx.Unlock() 134 | cs.states = append(cs.states, state) 135 | } 136 | 137 | func (cs *ConnStatesRecorder) Listen(r *ably.Realtime) (off func()) { 138 | cs.append(r.Connection.State()) 139 | return r.Connection.OnAll(func(c ably.ConnectionStateChange) { 140 | cs.append(c.Current) 141 | }) 142 | } 143 | 144 | func (cs *ConnStatesRecorder) States() []ably.ConnectionState { 145 | cs.mtx.Lock() 146 | defer cs.mtx.Unlock() 147 | return cs.states 148 | } 149 | 150 | type ChanStatesRecorder struct { 151 | mtx sync.Mutex 152 | states []ably.ChannelState 153 | } 154 | 155 | func (cs *ChanStatesRecorder) append(state ably.ChannelState) { 156 | cs.mtx.Lock() 157 | defer cs.mtx.Unlock() 158 | cs.states = append(cs.states, state) 159 | } 160 | 161 | func (cs *ChanStatesRecorder) Listen(channel *ably.RealtimeChannel) (off func()) { 162 | cs.append(channel.State()) 163 | return channel.OnAll(func(c ably.ChannelStateChange) { 164 | cs.append(c.Current) 165 | }) 166 | } 167 | 168 | func (cs *ChanStatesRecorder) States() []ably.ChannelState { 169 | cs.mtx.Lock() 170 | defer cs.mtx.Unlock() 171 | return cs.states 172 | } 173 | 174 | type ConnErrorsRecorder struct { 175 | mtx sync.Mutex 176 | errors []*ably.ErrorInfo 177 | } 178 | 179 | func (ce *ConnErrorsRecorder) appendNonNil(err *ably.ErrorInfo) { 180 | ce.mtx.Lock() 181 | defer ce.mtx.Unlock() 182 | if err != nil { 183 | ce.errors = append(ce.errors, err) 184 | } 185 | } 186 | 187 | func (ce *ConnErrorsRecorder) Listen(r *ably.Realtime) (off func()) { 188 | ce.appendNonNil(r.Connection.ErrorReason()) 189 | return r.Connection.OnAll(func(c ably.ConnectionStateChange) { 190 | ce.appendNonNil(c.Reason) 191 | }) 192 | } 193 | 194 | func (ce *ConnErrorsRecorder) Errors() []*ably.ErrorInfo { 195 | ce.mtx.Lock() 196 | defer ce.mtx.Unlock() 197 | return ce.errors 198 | } 199 | 200 | // FullRealtimeCloser returns an io.Closer that, on Close, calls Close on the 201 | // Realtime instance and waits for its effects. 202 | func FullRealtimeCloser(c *ably.Realtime) io.Closer { 203 | return realtimeIOCloser{c: c} 204 | } 205 | 206 | type realtimeIOCloser struct { 207 | c *ably.Realtime 208 | } 209 | 210 | func (c realtimeIOCloser) Close() error { 211 | switch c.c.Connection.State() { 212 | case 213 | ably.ConnectionStateInitialized, 214 | ably.ConnectionStateClosed, 215 | ably.ConnectionStateFailed: 216 | 217 | return nil 218 | } 219 | 220 | ctx, cancel := context.WithTimeout(context.Background(), Timeout) 221 | defer cancel() 222 | 223 | errCh := make(chan error, 1) 224 | 225 | off := make(chan func(), 1) 226 | off <- c.c.Connection.OnAll(func(c ably.ConnectionStateChange) { 227 | switch c.Current { 228 | default: 229 | return 230 | case 231 | ably.ConnectionStateClosed, 232 | ably.ConnectionStateFailed: 233 | } 234 | 235 | (<-off)() 236 | 237 | var err error 238 | if c.Reason != nil { 239 | err = *c.Reason 240 | } 241 | errCh <- err 242 | }) 243 | 244 | c.c.Close() 245 | 246 | select { 247 | case err := <-errCh: 248 | return err 249 | case <-ctx.Done(): 250 | return ctx.Err() 251 | } 252 | } 253 | 254 | func body(p []byte) io.ReadCloser { 255 | return io.NopCloser(bytes.NewReader(p)) 256 | } 257 | -------------------------------------------------------------------------------- /ablytest/resultgroup.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/ably/ably-go/ably" 11 | ) 12 | 13 | type Result interface { 14 | Wait(context.Context) error 15 | } 16 | 17 | func Wait(res Result, err error) error { 18 | if err != nil { 19 | return err 20 | } 21 | errch := make(chan error) 22 | go func() { 23 | errch <- res.Wait(context.Background()) 24 | }() 25 | select { 26 | case err := <-errch: 27 | return err 28 | case <-time.After(Timeout): 29 | return errors.New("waiting on Result timed out after " + Timeout.String()) 30 | } 31 | } 32 | 33 | // ResultGroup is like sync.WaitGroup, but for Result values. 34 | // 35 | // ResultGroup blocks till last added Result has completed successfully. 36 | // 37 | // If at least Result value failed, ResultGroup returns first encountered 38 | // error immediately. 39 | type ResultGroup struct { 40 | mu sync.Mutex 41 | wg sync.WaitGroup 42 | err error 43 | errch chan error 44 | } 45 | 46 | func (rg *ResultGroup) check(err error) bool { 47 | rg.mu.Lock() 48 | defer rg.mu.Unlock() 49 | if rg.errch == nil { 50 | rg.errch = make(chan error, 1) 51 | } 52 | rg.err = nonil(rg.err, err) 53 | return rg.err == nil 54 | } 55 | 56 | func (rg *ResultGroup) Add(res Result, err error) { 57 | if !rg.check(err) { 58 | return 59 | } 60 | rg.wg.Add(1) 61 | go func() { 62 | err := res.Wait(context.Background()) 63 | if err != nil && err != (*ably.ErrorInfo)(nil) { 64 | select { 65 | case rg.errch <- err: 66 | default: 67 | } 68 | } 69 | rg.wg.Done() 70 | }() 71 | } 72 | 73 | func (rg *ResultGroup) GoAdd(f ResultFunc) { 74 | rg.Add(f.Go(), nil) 75 | } 76 | 77 | func (rg *ResultGroup) Wait() error { 78 | if rg.err != nil { 79 | return rg.err 80 | } 81 | done := make(chan struct{}) 82 | go func() { 83 | rg.wg.Wait() 84 | done <- struct{}{} 85 | }() 86 | select { 87 | case <-done: 88 | return nil 89 | case err := <-rg.errch: 90 | return err 91 | } 92 | } 93 | 94 | type resultFunc func(context.Context) error 95 | 96 | func (f resultFunc) Wait(ctx context.Context) error { 97 | return f(ctx) 98 | } 99 | 100 | func AssertionWaiter(assertion func() bool) Result { 101 | return resultFunc(func(ctx context.Context) error { 102 | for { 103 | select { 104 | case <-ctx.Done(): 105 | return ctx.Err() 106 | default: 107 | time.Sleep(time.Millisecond * 200) 108 | if assertion() { 109 | return nil 110 | } 111 | } 112 | } 113 | }) 114 | } 115 | 116 | func ConnWaiter(client *ably.Realtime, do func(), expectedEvent ...ably.ConnectionEvent) Result { 117 | change := make(chan ably.ConnectionStateChange, 1) 118 | off := client.Connection.OnAll(func(ev ably.ConnectionStateChange) { 119 | change <- ev 120 | }) 121 | if do != nil { 122 | do() 123 | } 124 | for _, ev := range expectedEvent { 125 | if ev == ably.ConnectionEvent(client.Connection.State()) { 126 | var err error 127 | if errInfo := client.Connection.ErrorReason(); errInfo != nil { 128 | err = errInfo 129 | } 130 | return ResultFunc(func(context.Context) error { return err }) 131 | } 132 | } 133 | return ResultFunc(func(ctx context.Context) error { 134 | defer off() 135 | timer := time.After(Timeout) 136 | 137 | for { 138 | select { 139 | case <-ctx.Done(): 140 | return ctx.Err() 141 | 142 | case <-timer: 143 | return fmt.Errorf("timeout waiting for event %v", expectedEvent) 144 | 145 | case change := <-change: 146 | if change.Reason != nil { 147 | return change.Reason 148 | } 149 | 150 | if len(expectedEvent) > 0 { 151 | for _, ev := range expectedEvent { 152 | if ev == change.Event { 153 | return nil 154 | } 155 | } 156 | continue 157 | } 158 | 159 | return nil 160 | } 161 | } 162 | }) 163 | } 164 | 165 | type ResultFunc func(context.Context) error 166 | 167 | func (f ResultFunc) Wait(ctx context.Context) error { 168 | return f(ctx) 169 | } 170 | 171 | func (f ResultFunc) Go() Result { 172 | err := make(chan error, 1) 173 | go func() { 174 | err <- f(context.Background()) 175 | }() 176 | return ResultFunc(func(ctx context.Context) error { 177 | select { 178 | case <-ctx.Done(): 179 | return ctx.Err() 180 | case err := <-err: 181 | return err 182 | } 183 | }) 184 | } 185 | -------------------------------------------------------------------------------- /ablytest/test_utils.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | ) 7 | 8 | func GetReconnectionTimersFrom(t *testing.T, afterCalls <-chan AfterCall) (reconnect AfterCall, suspend AfterCall) { 9 | var timers []AfterCall 10 | for i := 0; i < 2; i++ { 11 | var timer AfterCall 12 | Instantly.Recv(t, &timer, afterCalls, t.Fatalf) 13 | timers = append(timers, timer) 14 | } 15 | // Shortest timer is for reconnection. 16 | sort.Slice(timers, func(i, j int) bool { 17 | return timers[i].D < timers[j].D 18 | }) 19 | reconnect, suspend = timers[0], timers[1] 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /ablytest/timeout.go: -------------------------------------------------------------------------------- 1 | package ablytest 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var Instantly = Before(10 * time.Millisecond) 11 | 12 | var Soon = Before(Timeout) 13 | 14 | // Before returns a WithTimeout with the given timeout duration. 15 | func Before(d time.Duration) WithTimeout { 16 | return WithTimeout{before: d} 17 | } 18 | 19 | // WithTimeout configures test helpers with a timeout. 20 | type WithTimeout struct { 21 | before time.Duration 22 | } 23 | 24 | // Recv asserts that a value is received through channel from before the 25 | // timeout. If it isn't, the fail function is called. 26 | // 27 | // If into is non-nil, it must be a pointer to a variable of the same type as 28 | // from's element type and it will be set to the received value, if any. 29 | // 30 | // It returns the second, boolean value returned by the receive operation, 31 | // or false if the operation times out. 32 | func (wt WithTimeout) Recv(t *testing.T, into, from interface{}, fail func(fmt string, args ...interface{}), failExtraArgs ...interface{}) (ok bool) { 33 | t.Helper() 34 | ok, timeout := wt.recv(into, from) 35 | if timeout { 36 | fail("timed out waiting for channel receive" + fmtExtraArgs(failExtraArgs)) 37 | } 38 | return ok 39 | } 40 | 41 | // NoRecv is like Recv, except it asserts no value is received. 42 | func (wt WithTimeout) NoRecv(t *testing.T, into, from interface{}, fail func(fmt string, args ...interface{}), failExtraArgs ...interface{}) (ok bool) { 43 | t.Helper() 44 | if into == nil { 45 | into = &into 46 | } 47 | ok, timeout := wt.recv(into, from) 48 | if !timeout { 49 | fail("unexpectedly received in channel: %v"+fmtExtraArgs(failExtraArgs), into) 50 | } 51 | return ok 52 | } 53 | 54 | // Send is like Recv, except it sends. 55 | func (wt WithTimeout) Send(t *testing.T, ch, v interface{}, fail func(fmt string, args ...interface{}), failExtraArgs ...interface{}) (ok bool) { 56 | t.Helper() 57 | if timeout := wt.send(ch, v); timeout { 58 | fail("timed out waiting for channel send" + fmtExtraArgs(failExtraArgs)) 59 | } 60 | return ok 61 | } 62 | 63 | func fmtExtraArgs(args []interface{}) string { 64 | if len(args) == 0 { 65 | return "" 66 | } 67 | return fmt.Sprintf(" [%s]", fmt.Sprint(args...)) 68 | } 69 | 70 | func (wt WithTimeout) recv(into, from interface{}) (ok, timeout bool) { 71 | chosen, recv, ok := reflect.Select([]reflect.SelectCase{{ 72 | Dir: reflect.SelectRecv, 73 | Chan: reflect.ValueOf(from), 74 | }, { 75 | Dir: reflect.SelectRecv, 76 | Chan: reflect.ValueOf(time.After(wt.before)), 77 | }}) 78 | if chosen == 0 && ok && into != nil { 79 | reflect.ValueOf(into).Elem().Set(recv) 80 | } 81 | return ok, chosen == 1 82 | } 83 | 84 | func (wt WithTimeout) send(ch, v interface{}) (timeout bool) { 85 | chosen, _, _ := reflect.Select([]reflect.SelectCase{{ 86 | Dir: reflect.SelectSend, 87 | Chan: reflect.ValueOf(ch), 88 | Send: reflect.ValueOf(v), 89 | }, { 90 | Dir: reflect.SelectRecv, 91 | Chan: reflect.ValueOf(time.After(wt.before)), 92 | }}) 93 | return chosen == 1 94 | } 95 | 96 | func (wt WithTimeout) IsTrue(pred func() bool) bool { 97 | ticker := time.NewTicker(10 * time.Millisecond) 98 | defer ticker.Stop() 99 | timeout := time.NewTimer(wt.before) 100 | defer timeout.Stop() 101 | for { 102 | if pred() { 103 | return true 104 | } 105 | select { 106 | case <-ticker.C: 107 | case <-timeout.C: 108 | return pred() 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # How to run 2 | 3 | 1. Realtime 4 | 5 | - Go to realtime dir `cd realtime` 6 | - Set the `ABLY_KEY` environment variable to your [Ably API key](https://faqs.ably.com/setting-up-and-managing-api-keys) 7 | - Realtime channel pub-sub 8 | 9 | `go run presence/main.go` 10 | 11 | 12 | - Realtime channel presence 13 | 14 | `go run pub-sub/main.go` 15 | 16 | 2. REST 17 | 18 | - Go to rest dir `cd rest` 19 | - Set the `ABLY_KEY` environment variable to your [Ably API key](https://faqs.ably.com/setting-up-and-managing-api-keys) 20 | - Rest channel publish 21 | 22 | `go run publish/main.go` 23 | 24 | - Rest channel presence 25 | 26 | `go run presence/main.go` 27 | 28 | - Rest channel message history 29 | 30 | `go run history/main.go` 31 | 32 | - Rest application stats 33 | 34 | `go run stats/main.go` 35 | -------------------------------------------------------------------------------- /examples/constants.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | const EventName = "message" 4 | const UserName = "testUser" 5 | const AblyKey = "ABLY_KEY" 6 | const ChannelName = "chat" 7 | -------------------------------------------------------------------------------- /examples/realtime/presence/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/ably/ably-go/examples" 11 | ) 12 | 13 | func main() { 14 | // Connect to Ably using the API key and ClientID specified 15 | client, err := ably.NewRealtime( 16 | ably.WithKey(os.Getenv(examples.AblyKey)), 17 | ably.WithClientID(examples.UserName)) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | checkPresenceEnter(client) 23 | checkPresenceLeave(client) 24 | checkPresenceEnterAndLeave(client) 25 | } 26 | 27 | func checkPresenceEnter(client *ably.Realtime) { 28 | channel := client.Channels.Get(examples.ChannelName) 29 | unsubscribe := subscribePresenceEnter(channel) 30 | enterPresence(channel) 31 | time.Sleep(time.Second) 32 | unsubscribe() 33 | } 34 | 35 | func checkPresenceLeave(client *ably.Realtime) { 36 | channel := client.Channels.Get(examples.ChannelName) 37 | unsubscribe := subscribePresenceLeave(channel) 38 | leavePresence(channel) 39 | time.Sleep(time.Second) 40 | unsubscribe() 41 | } 42 | 43 | func checkPresenceEnterAndLeave(client *ably.Realtime) { 44 | channel := client.Channels.Get(examples.ChannelName) 45 | unsubscribe := subscribeAllPresence(channel) 46 | enterPresence(channel) 47 | printAllClientsOnChannel(channel) 48 | leavePresence(channel) 49 | time.Sleep(time.Second) 50 | unsubscribe() 51 | } 52 | 53 | func enterPresence(channel *ably.RealtimeChannel) { 54 | pErr := channel.Presence.Enter(context.Background(), examples.UserName+" entered the channel") 55 | if pErr != nil { 56 | err := fmt.Errorf("error with enter presence on the channel %w", pErr) 57 | fmt.Println(err) 58 | } 59 | } 60 | 61 | func enterOnBehalfOf(clientId string, channel *ably.RealtimeChannel) { 62 | pErr := channel.Presence.EnterClient(context.Background(), clientId, examples.UserName+" entered the channel on behalf of "+clientId) 63 | if pErr != nil { 64 | err := fmt.Errorf("error with enter presence on behalf of other client on the channel %w", pErr) 65 | fmt.Println(err) 66 | } 67 | } 68 | 69 | func updatePresence(channel *ably.RealtimeChannel) { 70 | pErr := channel.Presence.Update(context.Background(), examples.UserName+" entered the channel") 71 | if pErr != nil { 72 | err := fmt.Errorf("error with update presence on the channel %w", pErr) 73 | fmt.Println(err) 74 | } 75 | } 76 | 77 | func leavePresence(channel *ably.RealtimeChannel) { 78 | pErr := channel.Presence.Leave(context.Background(), examples.UserName+" entered the channel") 79 | if pErr != nil { 80 | err := fmt.Errorf("error with leave presence on the channel %w", pErr) 81 | fmt.Println(err) 82 | } 83 | } 84 | 85 | func subscribeAllPresence(channel *ably.RealtimeChannel) func() { 86 | // Subscribe to presence events (people entering and leaving) on the channel 87 | unsubscribeAll, pErr := channel.Presence.SubscribeAll(context.Background(), func(msg *ably.PresenceMessage) { 88 | if msg.Action == ably.PresenceActionEnter { 89 | fmt.Printf("%v has entered the chat\n", msg.ClientID) 90 | } else if msg.Action == ably.PresenceActionLeave { 91 | fmt.Printf("%v has left the chat\n", msg.ClientID) 92 | } 93 | }) 94 | if pErr != nil { 95 | err := fmt.Errorf("error subscribing to presence in channel: %w", pErr) 96 | fmt.Println(err) 97 | 98 | } 99 | return unsubscribeAll 100 | } 101 | 102 | func subscribePresenceEnter(channel *ably.RealtimeChannel) func() { 103 | // Subscribe to presence events entering the channel 104 | unsubscribe, pErr := channel.Presence.Subscribe(context.Background(), ably.PresenceActionEnter, func(msg *ably.PresenceMessage) { 105 | if msg.Action == ably.PresenceActionEnter { 106 | fmt.Printf("%v has entered the chat\n", msg.ClientID) 107 | } else { 108 | panic("Not supposed to get presence related to actions other than presence enter") 109 | } 110 | }) 111 | 112 | if pErr != nil { 113 | err := fmt.Errorf("error subscribing to enter presence in channel: %w", pErr) 114 | fmt.Println(err) 115 | } 116 | return unsubscribe 117 | } 118 | 119 | func subscribePresenceLeave(channel *ably.RealtimeChannel) func() { 120 | // Subscribe to presence events leaving the channel 121 | unsubscribe, pErr := channel.Presence.Subscribe(context.Background(), ably.PresenceActionLeave, func(msg *ably.PresenceMessage) { 122 | if msg.Action == ably.PresenceActionLeave { 123 | fmt.Printf("%v has left the chat\n", msg.ClientID) 124 | } else { 125 | panic("Not supposed to get presence related actions other than presence leave") 126 | } 127 | }) 128 | if pErr != nil { 129 | err := fmt.Errorf("error subscribing to leave presence in channel: %w", pErr) 130 | fmt.Println(err) 131 | } 132 | return unsubscribe 133 | } 134 | 135 | func printAllClientsOnChannel(channel *ably.RealtimeChannel) { 136 | clients, err := channel.Presence.Get(context.Background()) 137 | if err != nil { 138 | panic(err) 139 | } 140 | for _, client := range clients { 141 | fmt.Println("Present client:", client) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /examples/realtime/pub-sub/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/ably/ably-go/examples" 11 | ) 12 | 13 | func main() { 14 | // Connect to Ably using the API key and ClientID specified 15 | client, err := ably.NewRealtime( 16 | ably.WithKey(os.Getenv(examples.AblyKey)), 17 | ably.WithClientID(examples.UserName)) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | checkSubscribeAll(client) 23 | checkSubscribeToEvent(client) 24 | } 25 | 26 | func checkSubscribeAll(client *ably.Realtime) { 27 | 28 | channel := client.Channels.Get(examples.ChannelName) 29 | 30 | unsubscribeAll := subscribeAll(channel) 31 | 32 | publish(channel, "Hey there !!") 33 | 34 | time.Sleep(time.Second) 35 | 36 | unsubscribeAll() 37 | } 38 | 39 | func checkSubscribeToEvent(client *ably.Realtime) { 40 | // Connect to the Ably Channel with name 'chat' 41 | channel := client.Channels.Get(examples.ChannelName) 42 | 43 | unsubscribe := subscribeToEvent(channel) 44 | 45 | // publish message with blocking call 46 | publish(channel, "Hey there !!") 47 | 48 | time.Sleep(time.Second) 49 | 50 | unsubscribe() 51 | } 52 | 53 | func subscribeToEvent(channel *ably.RealtimeChannel) func() { 54 | // Subscribe to messages sent on the channel with given eventName 55 | unsubscribe, err := channel.Subscribe(context.Background(), examples.EventName, func(msg *ably.Message) { 56 | fmt.Printf("Received message from %v: '%v'\n", msg.ClientID, msg.Data) 57 | }) 58 | if err != nil { 59 | err := fmt.Errorf("error subscribing to channel: %w", err) 60 | fmt.Println(err) 61 | } 62 | return unsubscribe 63 | } 64 | 65 | func subscribeAll(channel *ably.RealtimeChannel) func() { 66 | // Subscribe to all messages sent on the channel 67 | unsubscribeAll, err := channel.SubscribeAll(context.Background(), func(msg *ably.Message) { 68 | fmt.Printf("Received message from %v: '%v'\n", msg.ClientID, msg.Data) 69 | }) 70 | if err != nil { 71 | err := fmt.Errorf("error subscribing to channel: %w", err) 72 | fmt.Println(err) 73 | } 74 | return unsubscribeAll 75 | } 76 | 77 | func publish(channel *ably.RealtimeChannel, message string) { 78 | // Publish the message to Ably Channel 79 | err := channel.Publish(context.Background(), examples.EventName, message) 80 | if err != nil { 81 | err := fmt.Errorf("error publishing to channel: %w", err) 82 | fmt.Println(err) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /examples/rest/history/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/ably/ably-go/examples" 11 | ) 12 | 13 | func main() { 14 | // Connect to Ably using the API key and ClientID 15 | client, err := ably.NewREST( 16 | ably.WithKey(os.Getenv(examples.AblyKey)), 17 | ably.WithClientID(examples.UserName)) 18 | 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | checkRestChannelMessageHistory(client) 24 | checkRestChannelPresenceHistory(client) 25 | } 26 | 27 | func checkRestChannelMessageHistory(client *ably.REST) { 28 | channel := client.Channels.Get(examples.ChannelName) 29 | realtimeClient := examples.InitRealtimeClient() 30 | examples.RealtimePublish(realtimeClient, "Hey there!") 31 | examples.RealtimePublish(realtimeClient, "How are you") 32 | 33 | time.Sleep(time.Second) 34 | printChannelMessageHistory(channel) 35 | time.Sleep(time.Second) 36 | realtimeClient.Close() 37 | } 38 | 39 | func checkRestChannelPresenceHistory(client *ably.REST) { 40 | channel := client.Channels.Get(examples.ChannelName) 41 | realtimeClient := examples.InitRealtimeClient() 42 | examples.RealtimeEnterPresence(realtimeClient) 43 | 44 | time.Sleep(time.Second) 45 | printChannelPresenceHistory(channel) 46 | time.Sleep(time.Second) 47 | realtimeClient.Close() 48 | } 49 | 50 | func printChannelMessageHistory(channel *ably.RESTChannel) { 51 | pages, err := channel.History().Pages(context.Background()) 52 | if err != nil { 53 | panic(err) 54 | } 55 | for pages.Next(context.Background()) { 56 | for _, message := range pages.Items() { 57 | fmt.Println("--- Channel history ---") 58 | fmt.Println(examples.Jsonify(message)) 59 | fmt.Println("--------") 60 | } 61 | } 62 | if err := pages.Err(); err != nil { 63 | panic(err) 64 | } 65 | } 66 | 67 | func printChannelPresenceHistory(channel *ably.RESTChannel) { 68 | pages, err := channel.Presence.History().Pages(context.Background()) 69 | if err != nil { 70 | panic(err) 71 | } 72 | for pages.Next(context.Background()) { 73 | for _, presence := range pages.Items() { 74 | fmt.Println("--- Channel presence history ---") 75 | fmt.Println(examples.Jsonify(presence)) 76 | fmt.Println("----------") 77 | } 78 | } 79 | if err := pages.Err(); err != nil { 80 | panic(err) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /examples/rest/presence/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/ably/ably-go/examples" 11 | ) 12 | 13 | func main() { 14 | // Connect to Ably using the API key and ClientID 15 | client, err := ably.NewREST( 16 | ably.WithKey(os.Getenv(examples.AblyKey)), 17 | ably.WithClientID(examples.UserName)) 18 | 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | checkPresence(client) 24 | } 25 | 26 | func checkPresence(client *ably.REST) { 27 | channel := client.Channels.Get(examples.ChannelName) 28 | realtimeClient := examples.InitRealtimeClient() 29 | examples.RealtimeEnterPresence(realtimeClient) 30 | 31 | printPresenceMessages(channel) 32 | 33 | time.Sleep(time.Second) 34 | examples.RealtimeLeavePresence(realtimeClient) 35 | realtimeClient.Close() 36 | } 37 | 38 | func printPresenceMessages(channel *ably.RESTChannel) { 39 | 40 | pages, err := channel.Presence.Get().Pages(context.Background()) 41 | if err != nil { 42 | panic(err) 43 | } 44 | for pages.Next(context.Background()) { 45 | for _, presence := range pages.Items() { 46 | fmt.Println("--- Channel presence ---") 47 | fmt.Println(examples.Jsonify(presence)) 48 | fmt.Println("----------") 49 | } 50 | } 51 | if err := pages.Err(); err != nil { 52 | panic(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/rest/publish/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/ably/ably-go/ably" 10 | "github.com/ably/ably-go/examples" 11 | ) 12 | 13 | func main() { 14 | // Connect to Ably using the API key and ClientID 15 | client, err := ably.NewREST( 16 | ably.WithKey(os.Getenv(examples.AblyKey)), 17 | ably.WithClientID(examples.UserName)) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | checkRestPublish(client) 23 | checkRestBulkPublish(client) 24 | } 25 | 26 | func checkRestPublish(client *ably.REST) { 27 | channel := client.Channels.Get(examples.ChannelName) 28 | realtimeClient := examples.InitRealtimeClient() 29 | unsubscribe := examples.RealtimeSubscribeToEvent(realtimeClient) 30 | 31 | restPublish(channel, "Hey there") 32 | 33 | time.Sleep(time.Second) 34 | unsubscribe() 35 | realtimeClient.Close() 36 | } 37 | 38 | func checkRestBulkPublish(client *ably.REST) { 39 | channel := client.Channels.Get(examples.ChannelName) 40 | realtimeClient := examples.InitRealtimeClient() 41 | unsubscribe := examples.RealtimeSubscribeToEvent(realtimeClient) 42 | 43 | restPublishBatch(channel, "Hey there", "How are you?") 44 | 45 | time.Sleep(time.Second) 46 | unsubscribe() 47 | realtimeClient.Close() 48 | } 49 | 50 | func restPublish(channel *ably.RESTChannel, message string) { 51 | 52 | err := channel.Publish(context.Background(), examples.EventName, message) 53 | if err != nil { 54 | err := fmt.Errorf("error publishing to channel: %w", err) 55 | panic(err) 56 | } 57 | } 58 | 59 | func restPublishBatch(channel *ably.RESTChannel, message1 string, message2 string) { 60 | 61 | err := channel.PublishMultiple(context.Background(), []*ably.Message{ 62 | {Name: examples.EventName, Data: message1}, 63 | {Name: examples.EventName, Data: message2}, 64 | }) 65 | if err != nil { 66 | err := fmt.Errorf("error batch publishing to channel: %w", err) 67 | panic(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/rest/stats/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/ably/ably-go/ably" 9 | "github.com/ably/ably-go/examples" 10 | ) 11 | 12 | func main() { 13 | // Connect to Ably using the API key and ClientID 14 | client, err := ably.NewREST( 15 | ably.WithKey(os.Getenv(examples.AblyKey)), 16 | ably.WithClientID(examples.UserName)) 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | printApplicationStats(client) 22 | } 23 | 24 | func printApplicationStats(client *ably.REST) { 25 | pages, err := client.Stats().Pages(context.Background()) 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | for pages.Next(context.Background()) { 31 | for _, stat := range pages.Items() { 32 | fmt.Println(examples.Jsonify(stat)) 33 | } 34 | } 35 | if err := pages.Err(); err != nil { 36 | panic(err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/rest/status/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/ably/ably-go/ably" 9 | "github.com/ably/ably-go/examples" 10 | ) 11 | 12 | func main() { 13 | // Connect to Ably using the API key and ClientID 14 | client, err := ably.NewREST( 15 | ably.WithKey(os.Getenv(examples.AblyKey)), 16 | ably.WithClientID(examples.UserName)) 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | // Get channel 22 | channel := client.Channels.Get("channelName") 23 | // Get the channel status 24 | status, err := channel.Status(context.Background()) 25 | if err != nil { 26 | panic(err) 27 | } 28 | fmt.Print(status, status.ChannelId) 29 | 30 | } 31 | -------------------------------------------------------------------------------- /examples/utils.go: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/ably/ably-go/ably" 10 | ) 11 | 12 | func InitRealtimeClient() *ably.Realtime { 13 | client, err := ably.NewRealtime( 14 | ably.WithKey(os.Getenv(AblyKey)), 15 | // ably.WithEchoMessages(true), // Uncomment to stop messages you send from being sent back 16 | ably.WithClientID(UserName)) 17 | if err != nil { 18 | panic(err) 19 | } 20 | return client 21 | } 22 | 23 | func RealtimeSubscribeToEvent(client *ably.Realtime) func() { 24 | channel := client.Channels.Get(ChannelName) 25 | 26 | // Subscribe to messages sent on the channel 27 | unsubscribe, err := channel.Subscribe(context.Background(), EventName, func(msg *ably.Message) { 28 | fmt.Printf("Received message from %v: '%v'\n", msg.ClientID, msg.Data) 29 | }) 30 | if err != nil { 31 | err := fmt.Errorf("error subscribing to channel: %w", err) 32 | fmt.Println(err) 33 | } 34 | return unsubscribe 35 | } 36 | 37 | func RealtimeEnterPresence(client *ably.Realtime) { 38 | channel := client.Channels.Get(ChannelName) 39 | pErr := channel.Presence.Enter(context.Background(), UserName+" entered the channel") 40 | if pErr != nil { 41 | err := fmt.Errorf("error with enter presence on the channel %w", pErr) 42 | fmt.Println(err) 43 | } 44 | } 45 | 46 | func RealtimeLeavePresence(client *ably.Realtime) { 47 | channel := client.Channels.Get(ChannelName) 48 | pErr := channel.Presence.Leave(context.Background(), UserName+" entered the channel") 49 | if pErr != nil { 50 | err := fmt.Errorf("error with leave presence on the channel %w", pErr) 51 | fmt.Println(err) 52 | } 53 | } 54 | 55 | func RealtimePublish(client *ably.Realtime, message string) { 56 | channel := client.Channels.Get(ChannelName) 57 | // Publish the message typed in to the Ably Channel 58 | err := channel.Publish(context.Background(), EventName, message) 59 | if err != nil { 60 | err := fmt.Errorf("error publishing to channel: %w", err) 61 | fmt.Println(err) 62 | } 63 | } 64 | 65 | func Jsonify(i interface{}) string { 66 | s, _ := json.MarshalIndent(i, "", "\t") 67 | return string(s) 68 | } 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ably/ably-go 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/stretchr/testify v1.7.1 7 | github.com/ugorji/go/codec v1.1.9 8 | golang.org/x/sys v0.2.0 9 | nhooyr.io/websocket v1.8.7 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/klauspost/compress v1.10.3 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 5 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 6 | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= 7 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 8 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 9 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 10 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 11 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 12 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 13 | github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= 14 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= 15 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= 16 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= 17 | github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= 18 | github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 19 | github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= 20 | github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= 21 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 22 | github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= 23 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 24 | github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= 25 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 26 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 27 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= 28 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 29 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 30 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 31 | github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= 32 | github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 33 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 34 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 35 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 36 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 37 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 38 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 39 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 40 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 44 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 45 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 46 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 47 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 48 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 49 | github.com/ugorji/go v1.1.9/go.mod h1:chLrngdsg43geAaeId+nXO57YsDdl5OZqd/QtBiD19g= 50 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 51 | github.com/ugorji/go/codec v1.1.9 h1:J/7hhpkQwgypRNvaeh/T5gzJ2gEI/l8S3qyRrdEa1fA= 52 | github.com/ugorji/go/codec v1.1.9/go.mod h1:+SWgpdqOgdW5sBaiDfkHilQ1SxQ1hBkq/R+kHfL7Suo= 53 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 54 | golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= 55 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 57 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 58 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 59 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 60 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 64 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 65 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 66 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 67 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 68 | nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= 69 | nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= 70 | -------------------------------------------------------------------------------- /scripts/ci-generate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # A script to be run with a clean working tree. 4 | # Runs the code generator and then ensures the working tree is still clean. 5 | 6 | set -e 7 | 8 | if [[ $(git diff --stat) != '' ]]; then 9 | echo 'FAILURE: Dirty working tree BEFORE code generation!' 10 | git diff 11 | exit 1 12 | fi 13 | 14 | go generate ./... 15 | 16 | if [[ $(git diff --stat) != '' ]]; then 17 | echo 'FAILURE: Dirty working tree after code generation.' 18 | git diff 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /scripts/errors/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "go/format" 9 | "go/scanner" 10 | "log" 11 | "os" 12 | "strings" 13 | "text/template" 14 | ) 15 | 16 | type options struct { 17 | jsonSource string 18 | template string 19 | output string 20 | } 21 | 22 | const version = "0.3.0" 23 | 24 | func main() { 25 | log.SetFlags(log.Llongfile) 26 | opts := &options{ 27 | template: errorsTemplate, 28 | } 29 | flag.StringVar(&opts.jsonSource, "json", "", `path to the ably-common/protocol/errors.json`) 30 | flag.StringVar(&opts.output, "o", "", "file to write output") 31 | flag.Parse() 32 | if opts.jsonSource == "" { 33 | flag.PrintDefaults() 34 | return 35 | } 36 | sourceFile, err := os.ReadFile(opts.jsonSource) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | codeToDesc := make(map[int]string) 42 | err = json.Unmarshal(sourceFile, &codeToDesc) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | tpl, err := template.New("errors").Parse(opts.template) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | var buf bytes.Buffer 51 | err = tpl.Execute(&buf, map[string]interface{}{ 52 | "codes": codeToDesc, 53 | "version": version, 54 | }) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | goFile, err := format.Source(buf.Bytes()) 59 | if err != nil { 60 | var sErr scanner.ErrorList 61 | if errors.As(err, &sErr) { 62 | // Try to print out the line that caused the error. 63 | lines := strings.Split(buf.String(), "\n") 64 | pos := (sErr)[0].Pos 65 | log.Println("Error in line", pos.Line, lines[pos.Line]) 66 | } 67 | log.Fatal(err) 68 | } 69 | if opts.output != "" { 70 | err = os.WriteFile(opts.output, goFile, 0600) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | } 75 | } 76 | 77 | const errorsTemplate = `// Code generated by errors-const v{{.version}} tool DO NOT EDIT. 78 | 79 | package ably 80 | 81 | // ErrorCode is the type for predefined Ably error codes. 82 | type ErrorCode int 83 | 84 | func (c ErrorCode) String() string { 85 | switch c { 86 | case 0: 87 | return "(error code not set)" 88 | {{- range $code, $desc := .codes}} 89 | case {{ $code}}: return "{{ $desc}}" 90 | {{- end}} 91 | } 92 | return "" 93 | } 94 | ` 95 | --------------------------------------------------------------------------------