chorus/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5/*!
6Chorus is a Rust library which poses as an API wrapper for [Spacebar Chat](https://github.com/spacebarchat/),
7Discord and our own Polyphony. Its high-level API is designed to be easy to use, while still providing the
8flexibility one would expect from a library like this.
9
10You can establish as many connections to as many servers as you want, and you can use them all at the same time.
11
12## A Tour of Chorus
13
14Chorus combines all the required functionalities of an API wrapper for chat services into one modular library.
15The library handles various aspects on your behalf, such as rate limiting, authentication and maintaining
16a WebSocket connection to the Gateway. This means that you can focus on building your application,
17instead of worrying about the underlying implementation details.
18
19### Establishing a Connection
20
21To connect to a Polyphony/Spacebar compatible server, you'll need to create an [`Instance`](https://docs.rs/chorus/latest/chorus/instance/struct.Instance.html) like this:
22
23```rust
24use chorus::{instance::Instance, types::IntoShared};
25
26#[tokio::main]
27async fn main() {
28    let url = "https://example.com";
29    # let url = "http://localhost:3001";
30
31    // This instance will later need to be shared across threads and users, so we'll
32    // store it inside of the `Shared` type (note the `into_shared()` method call)
33    let instance = Instance::new(url, None)
34        .await
35        .expect("Failed to connect to the Spacebar server")
36        .into_shared();
37
38    // You can create as many instances of `Instance` as you want, but each `Instance` should likely be unique.
39
40    // Each time we want to access the underlying `Instance` we need to lock
41    // its reference so other threads don't modify the data while we're reading or changing it
42    let instance_lock = instance.read().unwrap();
43
44    dbg!(&instance_lock.instance_info);
45    dbg!(&instance_lock.limits_information);
46}
47```
48
49This Instance can now be used to log in, register and from there on, interact with the server in all sorts of ways.
50
51### Logging In
52
53Logging in correctly provides you with an instance of `ChorusUser`, with which you can interact with the server and
54manipulate the account. Assuming you already have an account on the server, you can log in like this:
55
56```no_run
57# tokio_test::block_on(async {
58use chorus::types::LoginSchema;
59# mod tests::common;
60# let mut bundle = tests::common::setup().await;
61# let instance = bundle.instance;
62// Assume, you already have an account created on this instance. Registering an account works
63// the same way, but you'd use the Register-specific Structs and methods instead.
64let login_schema = LoginSchema {
65    login: "user@example.com".to_string(),
66    password: "Correct-Horse-Battery-Staple".to_string(),
67    ..Default::default()
68};
69// Each user connects to the Gateway. Each users' Gateway connection lives on a separate thread. Depending on
70// the runtime feature you choose, this can potentially take advantage of all of your computers' threads.
71//
72// Note that we clone the reference to the instance here, not the instance itself
73// (we do this because each user needs its own access to the instance's data)
74let user = Instance::login_account(instance.clone(), login_schema)
75    .await
76    .expect("An error occurred during the login process");
77dbg!(user.belongs_to);
78dbg!(&user.object.read().unwrap().username);
79# tests::common::teardown(bundle).await;
80# })
81```
82
83## Supported Platforms
84
85All major desktop operating systems (Windows, macOS (aarch64/x86_64), Linux (aarch64/x86_64)) are supported.
86`wasm32-unknown-unknown` is a supported compilation target on versions `0.12.0` and up. This allows you to use
87Chorus in your browser, or in any other environment that supports WebAssembly.
88
89To compile for `wasm32-unknown-unknown`, execute the following command:
90
91```sh
92cargo build --target=wasm32-unknown-unknown --no-default-features
93```
94
95The following features are supported on `wasm32-unknown-unknown`:
96
97| Feature           | WASM Support |
98| ----------------- | ------------ |
99| `client`          | ✅            |
100| `rt`              | ✅            |
101| `rt-multi-thread` | ❌            |
102| `backend`         | ❌            |
103| `voice`           | ❌            |
104| `voice_udp`       | ❌            |
105| `voice_gateway`   | ✅            |
106
107We recommend checking out the "examples" directory, as well as the documentation for more information.
108
109## MSRV (Minimum Supported Rust Version)
110
111Rust **1.81.0**. This number might change at any point while Chorus is not yet at version 1.0.0.
112
113## Development Setup
114
115Make sure that you have at least Rust 1.81.0 installed. You can check your Rust version by running `cargo --version`
116in your terminal. To compile for `wasm32-unknown-unknown`, you need to install the `wasm32-unknown-unknown` target.
117You can do this by running `rustup target add wasm32-unknown-unknown`.
118
119### Testing
120
121In general, the tests will require you to run a local instance of the Spacebar server. You can find instructions on how
122to do that [here](https://docs.spacebar.chat/setup/server/). You can find a pre-configured version of the server
123[here](https://github.com/bitfl0wer/server). It is recommended to use the pre-configured version, as certain things
124like "proxy connection checking" are already disabled on this version, which otherwise might break tests.
125
126### wasm
127
128To test for wasm, you will need to `cargo install wasm-pack`. You can then run
129`wasm-pack test --<chrome/firefox/safari> --headless -- --target wasm32-unknown-unknown --features="rt, client, voice_gateway" --no-default-features`
130to run the tests for wasm.
131
132## Versioning
133
134This crate uses Semantic Versioning 2.0.0 as its versioning scheme. You can read the specification [here](https://semver.org/spec/v2.0.0.html).
135
136## Contributing
137
138See [CONTRIBUTING.md](./CONTRIBUTING.md).
139!*/
140#![doc(
141    html_logo_url = "https://raw.githubusercontent.com/polyphony-chat/design/main/branding/polyphony-chorus-round-8bit.png"
142)]
143#![allow(clippy::module_inception)]
144#![deny(
145    clippy::extra_unused_lifetimes,
146    clippy::from_over_into,
147    clippy::needless_borrow,
148    clippy::new_without_default
149)]
150#![warn(
151    clippy::todo,
152    clippy::unimplemented,
153    clippy::dbg_macro,
154    clippy::print_stdout,
155    clippy::print_stderr,
156    missing_debug_implementations,
157    missing_copy_implementations,
158    clippy::useless_conversion
159)]
160#[cfg(all(feature = "rt", feature = "rt_multi_thread"))]
161compile_error!("feature \"rt\" and feature \"rt_multi_thread\" cannot be enabled at the same time");
162
163use errors::ChorusResult;
164use serde::{Deserialize, Serialize};
165use types::types::domains_configuration::WellKnownResponse;
166use url::{ParseError, Url};
167
168use crate::errors::ChorusError;
169
170#[cfg(feature = "client")]
171pub mod api;
172pub mod errors;
173#[cfg(feature = "client")]
174pub mod gateway;
175#[cfg(feature = "client")]
176pub mod instance;
177#[cfg(feature = "client")]
178pub mod ratelimiter;
179pub mod types;
180#[cfg(all(
181    feature = "client",
182    any(feature = "voice_udp", feature = "voice_gateway")
183))]
184pub mod voice;
185
186#[cfg(not(feature = "sqlx"))]
187pub type UInt128 = u128;
188#[cfg(feature = "sqlx")]
189pub type UInt128 = sqlx_pg_uint::PgU128;
190#[cfg(not(feature = "sqlx"))]
191pub type UInt64 = u64;
192#[cfg(feature = "sqlx")]
193pub type UInt64 = sqlx_pg_uint::PgU64;
194#[cfg(not(feature = "sqlx"))]
195pub type UInt32 = u32;
196#[cfg(feature = "sqlx")]
197pub type UInt32 = sqlx_pg_uint::PgU32;
198#[cfg(not(feature = "sqlx"))]
199pub type UInt16 = u16;
200#[cfg(feature = "sqlx")]
201pub type UInt16 = sqlx_pg_uint::PgU16;
202#[cfg(not(feature = "sqlx"))]
203pub type UInt8 = u8;
204#[cfg(feature = "sqlx")]
205pub type UInt8 = sqlx_pg_uint::PgU8;
206
207#[derive(Clone, Default, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
208/// A URLBundle bundles together the API-, Gateway- and CDN-URLs of a Spacebar instance.
209///
210/// # Notes
211/// All the urls can be found on the /api/policies/instance/domains endpoint of a spacebar server
212pub struct UrlBundle {
213    /// The root url of an Instance. Usually, this would be the url where `.well-known/spacebar` can
214    /// be located under. If the instance you are connecting to for some reason does not have a
215    /// `.well-known` set up (for example, if it is a local/testing instance), you can use the api
216    /// url as a substitute.
217    /// Ex: `https://spacebar.chat`
218    pub root: String,
219    /// The api's url.
220    /// Ex: `https://old.server.spacebar.chat/api`
221    pub api: String,
222    /// The gateway websocket url.
223    /// Note that because this is a websocket url, it will always start with `wss://` or `ws://`
224    /// Ex: `wss://gateway.old.server.spacebar.chat`
225    pub wss: String,
226    /// The CDN's url.
227    /// Ex: `https://cdn.old.server.spacebar.chat`
228    pub cdn: String,
229}
230
231impl UrlBundle {
232    /// Creates a new UrlBundle from the relevant urls.
233    pub fn new(root: &str, api: &str, wss: &str, cdn: &str) -> Self {
234        Self {
235            root: UrlBundle::parse_url(root),
236            api: UrlBundle::parse_url(api),
237            wss: UrlBundle::parse_url(wss),
238            cdn: UrlBundle::parse_url(cdn),
239        }
240    }
241
242    /// Parses a URL using the Url library and formats it in a standardized way.
243    /// If no protocol is given, HTTP (not HTTPS) is assumed.
244    ///
245    /// # Examples:
246    /// ```rust
247    /// # use chorus::UrlBundle;
248    /// let url = UrlBundle::parse_url("localhost:3000");
249    /// ```
250    /// `-> Outputs "http://localhost:3000".`
251    pub fn parse_url(url: &str) -> String {
252        let url = match Url::parse(url) {
253            Ok(url) => {
254                if url.scheme() == "localhost" {
255                    return UrlBundle::parse_url(&format!("http://{}", url));
256                }
257                url
258            }
259            Err(ParseError::RelativeUrlWithoutBase) => {
260                let url_fmt = format!("http://{}", url);
261                return UrlBundle::parse_url(&url_fmt);
262            }
263            Err(_) => panic!("Invalid URL"), // TODO: should not panic here
264        };
265        // if the last character of the string is a slash, remove it.
266        let mut url_string = url.to_string();
267        if url_string.ends_with('/') {
268            url_string.pop();
269        }
270        url_string
271    }
272
273    /// Performs a few HTTP requests to try and retrieve a `UrlBundle` from an instances' root url.
274    /// The method tries to retrieve the `UrlBundle` via these three strategies, in order:
275    /// - GET: `$url/.well-known/spacebar` -> Retrieve UrlBundle via `$wellknownurl/api/policies/instance/domains`
276    /// - GET: `$url/api/policies/instance/domains`
277    /// - GET: `$url/policies/instance/domains`
278    ///
279    /// The URL stored at `.well-known/spacebar` is the instances' API endpoint. The API
280    /// stores the CDN and WSS URLs under the `$api/policies/instance/domains` endpoint. If all three
281    /// of the above approaches fail, it is very likely that the instance is misconfigured, unreachable, or that
282    /// a wrong URL was provided.
283    pub async fn from_root_url(url: &str) -> ChorusResult<UrlBundle> {
284        let parsed = UrlBundle::parse_url(url);
285        let client = reqwest::Client::new();
286        let request_wellknown = client
287            .get(format!("{}/.well-known/spacebar", &parsed))
288            .header(http::header::ACCEPT, "application/json")
289            .build()?;
290        let response_wellknown = client.execute(request_wellknown).await?;
291        if response_wellknown.status().is_success() {
292            let api_url = response_wellknown.json::<WellKnownResponse>().await?.api;
293
294            UrlBundle::from_api_url(&format!("{}/policies/instance/domains", api_url)).await
295        } else {
296            if let Ok(response_slash_api) =
297                UrlBundle::from_api_url(&format!("{}/api/policies/instance/domains", parsed)).await
298            {
299                return Ok(response_slash_api);
300            }
301            if let Ok(response_api) =
302                UrlBundle::from_api_url(&format!("{}/policies/instance/domains", parsed)).await
303            {
304                Ok(response_api)
305            } else {
306                Err(ChorusError::RequestFailed { url: parsed.to_string(), error: "Could not retrieve UrlBundle from url after trying 3 different approaches. Check the provided Url and make sure the instance is reachable.".to_string() } )
307            }
308        }
309    }
310
311    async fn from_api_url(url: &str) -> ChorusResult<UrlBundle> {
312        let client = reqwest::Client::new();
313        let request = client
314            .get(url)
315            .header(http::header::ACCEPT, "application/json")
316            .build()?;
317        let response = client.execute(request).await?;
318        if let Ok(body) = response
319            .json::<types::types::domains_configuration::Domains>()
320            .await
321        {
322            Ok(UrlBundle::new(
323                url,
324                &body.api_endpoint,
325                &body.gateway,
326                &body.cdn,
327            ))
328        } else {
329            Err(ChorusError::RequestFailed {
330                url: url.to_string(),
331                error: "Could not retrieve a UrlBundle from the given url. Check the provided url and make sure the instance is reachable.".to_string(),
332            })
333        }
334    }
335}
336
337#[cfg(test)]
338mod lib {
339    use super::*;
340
341    #[test]
342    fn test_parse_url() {
343        let mut result = UrlBundle::parse_url("localhost:3000/");
344        assert_eq!(result, "http://localhost:3000");
345        result = UrlBundle::parse_url("https://some.url.com/");
346        assert_eq!(result, String::from("https://some.url.com"));
347        result = UrlBundle::parse_url("https://some.url.com/");
348        assert_eq!(result, "https://some.url.com");
349        result = UrlBundle::parse_url("https://some.url.com");
350        assert_eq!(result, "https://some.url.com");
351    }
352}