diff --git a/Cargo.lock b/Cargo.lock index 589bae6ef7..ee35b84846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4884,6 +4884,7 @@ dependencies = [ "is-terminal", "itertools 0.12.1", "mimalloc", + "percent-encoding", "regex", "reqwest 0.12.9", "rustyline", @@ -4959,6 +4960,9 @@ dependencies = [ "tokio", "tokio-stream", "tokio-tungstenite", + "tower-http", + "tower-layer", + "tower-service", "tracing", "uuid", ] @@ -4979,6 +4983,7 @@ dependencies = [ "proptest", "serde", "serde_json", + "serde_with", "smallvec", "spacetimedb-lib", "spacetimedb-primitives", diff --git a/Cargo.toml b/Cargo.toml index 3d81c1b29e..eab8c101c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -192,6 +192,7 @@ nohash-hasher = "0.2" once_cell = "1.16" parking_lot = { version = "0.12.1", features = ["send_guard", "arc_lock"] } paste = "1.0" +percent-encoding = "2.3" petgraph = { version = "0.6.5", default-features = false } pin-project-lite = "0.2.9" postgres-types = "0.2.5" @@ -249,6 +250,8 @@ tokio-util = { version = "0.7.4", features = ["time"] } toml = "0.8" toml_edit = "0.22.22" tower-http = { version = "0.5", features = ["cors"] } +tower-layer = "0.3" +tower-service = "0.3" tracing = "0.1.37" tracing-appender = "0.2.2" tracing-core = "0.1.31" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5464750dea..8cdc10e714 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -47,6 +47,7 @@ itertools.workspace = true indicatif.workspace = true jsonwebtoken.workspace = true mimalloc.workspace = true +percent-encoding.workspace = true regex.workspace = true reqwest.workspace = true rustyline.workspace = true diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index ad935b5c83..0210663d89 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -7,7 +7,7 @@ use spacetimedb_lib::de::serde::DeserializeWrapper; use spacetimedb_lib::sats::ProductType; use spacetimedb_lib::Identity; -use crate::util::AuthHeader; +use crate::util::{AuthHeader, ResponseExt}; static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); @@ -23,10 +23,10 @@ impl Connection { pub fn db_uri(&self, endpoint: &str) -> String { [ &self.host, - "/database/", - endpoint, - "/", + "/v1/database/", &self.database_identity.to_hex(), + "/", + endpoint, ] .concat() } @@ -66,9 +66,8 @@ impl ClientApi { .get(self.con.db_uri("schema")) .query(&[("version", "9")]) .send() - .await? - .error_for_status()?; - let DeserializeWrapper(module_def) = res.json().await?; + .await?; + let DeserializeWrapper(module_def) = res.json_or_error().await?; Ok(module_def) } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index f0cde77951..0165bcf9d8 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -163,15 +163,15 @@ impl RawConfig { nickname: Some("local".to_string()), ecdsa_public_key: None, }; - let testnet = ServerConfig { - host: "testnet.spacetimedb.com".to_string(), + let maincloud = ServerConfig { + host: "maincloud.spacetimedb.com".to_string(), protocol: "https".to_string(), - nickname: Some("testnet".to_string()), + nickname: Some("maincloud".to_string()), ecdsa_public_key: None, }; RawConfig { default_server: local.nickname.clone(), - server_configs: vec![local, testnet], + server_configs: vec![local, maincloud], web_session_token: None, spacetimedb_token: None, } diff --git a/crates/cli/src/errors.rs b/crates/cli/src/errors.rs index 6de16692fb..6eaf1b5ebf 100644 --- a/crates/cli/src/errors.rs +++ b/crates/cli/src/errors.rs @@ -1,22 +1,7 @@ -use reqwest::{Response, StatusCode}; use thiserror::Error; -#[derive(Debug)] -pub enum RequestSource { - Client, - Server, -} - #[derive(Error, Debug)] pub enum CliError { - #[error("HTTP status {kind:?} error ({status}): {msg}")] - Request { - msg: String, - kind: RequestSource, - status: StatusCode, - }, - #[error(transparent)] - ReqWest(#[from] reqwest::Error), #[error("Config error: The option `{key}` not found")] Config { key: String }, #[error("Config error: The option `{key}` is not a `{kind}`, found: `{type}: {value}`", @@ -29,19 +14,3 @@ pub enum CliError { found: Box, }, } - -/// Turn a response into an error if the server returned an error. -pub async fn error_for_status(response: Response) -> Result { - let status = response.status(); - if let Some(kind) = status - .is_client_error() - .then_some(RequestSource::Client) - // Anything that is not a success is an error for the client, even a redirect that is not followed. - .or_else(|| (!status.is_success()).then_some(RequestSource::Server)) - { - let msg = response.text().await?; - return Err(CliError::Request { kind, msg, status }); - } - - Ok(response) -} diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index d2a4aea13d..e0d5c75613 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -23,7 +23,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let identity = database_identity(&config, database, server).await?; - let builder = reqwest::Client::new().post(format!("{}/database/delete/{}", config.get_host_url(server)?, identity)); + let builder = reqwest::Client::new().delete(format!("{}/v1/database/{}", config.get_host_url(server)?, identity)); let auth_header = get_auth_header(&mut config, false, server, !force).await?; let builder = add_auth_header_opt(builder, &auth_header); builder.send().await?.error_for_status()?; diff --git a/crates/cli/src/subcommands/dns.rs b/crates/cli/src/subcommands/dns.rs index b270aa271b..03f4e96b3e 100644 --- a/crates/cli/src/subcommands/dns.rs +++ b/crates/cli/src/subcommands/dns.rs @@ -1,13 +1,10 @@ use crate::common_args; use crate::config::Config; -use crate::util::{ - add_auth_header_opt, decode_identity, get_auth_header, get_login_token_or_log_in, spacetime_register_tld, -}; +use crate::util::{add_auth_header_opt, decode_identity, get_auth_header, get_login_token_or_log_in, ResponseExt}; use clap::ArgMatches; use clap::{Arg, Command}; -use reqwest::Url; -use spacetimedb_client_api_messages::name::{InsertDomainResult, RegisterTldResult}; +use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult}; pub fn cli() -> Command { Command::new("rename") @@ -37,32 +34,17 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let identity = decode_identity(&token)?; let auth_header = get_auth_header(&mut config, false, server, !force).await?; - match spacetime_register_tld(&mut config, domain, server, !force).await? { - RegisterTldResult::Success { domain } => { - println!("Registered domain: {}", domain); - } - RegisterTldResult::Unauthorized { domain } => { - return Err(anyhow::anyhow!("Domain is already registered by another: {}", domain)); - } - RegisterTldResult::AlreadyRegistered { domain } => { - println!("Domain is already registered by the identity you provided: {}", domain); - } - } + let domain: DomainName = domain.parse()?; - let builder = reqwest::Client::new().get(Url::parse_with_params( - format!("{}/database/set_name", config.get_host_url(server)?).as_str(), - [ - ("domain", domain.clone()), - ("database_identity", database_identity.clone()), - ("register_tld", "true".to_string()), - ], - )?); + let builder = reqwest::Client::new() + .post(format!( + "{}/v1/database/{database_identity}/names", + config.get_host_url(server)? + )) + .body(String::from(domain)); let builder = add_auth_header_opt(builder, &auth_header); - let res = builder.send().await?.error_for_status()?; - let bytes = res.bytes().await.unwrap(); - println!("{}", String::from_utf8_lossy(&bytes[..])); - let result: InsertDomainResult = serde_json::from_slice(&bytes[..]).unwrap(); + let result = builder.send().await?.json_or_error().await?; match result { InsertDomainResult::Success { domain, diff --git a/crates/cli/src/subcommands/energy.rs b/crates/cli/src/subcommands/energy.rs index 59b2cf528c..16b0e8fa56 100644 --- a/crates/cli/src/subcommands/energy.rs +++ b/crates/cli/src/subcommands/energy.rs @@ -60,7 +60,7 @@ async fn exec_status(mut config: Config, args: &ArgMatches) -> Result<(), anyhow }; let status = reqwest::Client::new() - .get(format!("{}/energy/{}", config.get_host_url(server)?, identity)) + .get(format!("{}/v1/energy/{}", config.get_host_url(server)?, identity)) .send() .await? .error_for_status()? diff --git a/crates/cli/src/subcommands/generate/typescript.rs b/crates/cli/src/subcommands/generate/typescript.rs index d08cec0500..3a51122a20 100644 --- a/crates/cli/src/subcommands/generate/typescript.rs +++ b/crates/cli/src/subcommands/generate/typescript.rs @@ -348,23 +348,23 @@ removeOnUpdate = (cb: (ctx: EventContext, onRow: {row_type}, newRow: {row_type}) writeln!(out, "}},"); writeln!( out, - "// Constructors which are used by the DBConnectionImpl to + "// Constructors which are used by the DbConnectionImpl to // extract type information from the generated RemoteModule. // // NOTE: This is not strictly necessary for `eventContextConstructor` because // all we do is build a TypeScript object which we could have done inside the // SDK, but if in the future we wanted to create a class this would be // necessary because classes have methods, so we'll keep it. -eventContextConstructor: (imp: DBConnectionImpl, event: Event) => {{ +eventContextConstructor: (imp: DbConnectionImpl, event: Event) => {{ return {{ - ...(imp as DBConnection), + ...(imp as DbConnection), event }} }}, -dbViewConstructor: (imp: DBConnectionImpl) => {{ +dbViewConstructor: (imp: DbConnectionImpl) => {{ return new RemoteTables(imp); }}, -reducersConstructor: (imp: DBConnectionImpl, setReducerFlags: SetReducerFlags) => {{ +reducersConstructor: (imp: DbConnectionImpl, setReducerFlags: SetReducerFlags) => {{ return new RemoteReducers(imp, setReducerFlags); }}, setReducerFlagsConstructor: () => {{ @@ -433,7 +433,7 @@ fn print_remote_reducers(module: &ModuleDef, out: &mut Indenter) { out.indent(1); writeln!( out, - "constructor(private connection: DBConnectionImpl, private setCallReducerFlags: SetReducerFlags) {{}}" + "constructor(private connection: DbConnectionImpl, private setCallReducerFlags: SetReducerFlags) {{}}" ); out.newline(); @@ -537,7 +537,7 @@ fn print_set_reducer_flags(module: &ModuleDef, out: &mut Indenter) { fn print_remote_tables(module: &ModuleDef, out: &mut Indenter) { writeln!(out, "export class RemoteTables {{"); out.indent(1); - writeln!(out, "constructor(private connection: DBConnectionImpl) {{}}"); + writeln!(out, "constructor(private connection: DbConnectionImpl) {{}}"); for table in iter_tables(module) { writeln!(out); @@ -571,12 +571,12 @@ fn print_subscription_builder(_module: &ModuleDef, out: &mut Indenter) { fn print_db_connection(_module: &ModuleDef, out: &mut Indenter) { writeln!( out, - "export class DBConnection extends DBConnectionImpl {{" + "export class DbConnection extends DbConnectionImpl {{" ); out.indent(1); writeln!( out, - "static builder = (): DBConnectionBuilder => {{" + "static builder = (): DbConnectionBuilder => {{" ); out.indent(1); writeln!( @@ -620,7 +620,7 @@ fn print_spacetimedb_imports(out: &mut Indenter) { "ConnectionId", "Timestamp", "TimeDuration", - "DBConnectionBuilder", + "DbConnectionBuilder", "TableCache", "BinaryWriter", "CallReducerFlags", @@ -630,8 +630,8 @@ fn print_spacetimedb_imports(out: &mut Indenter) { "ErrorContextInterface", "SubscriptionBuilderImpl", "BinaryReader", - "DBConnectionImpl", - "DBContext", + "DbConnectionImpl", + "DbContext", "Event", "deepEqual", ]; diff --git a/crates/cli/src/subcommands/list.rs b/crates/cli/src/subcommands/list.rs index 51c0272e2e..095a5cd6bb 100644 --- a/crates/cli/src/subcommands/list.rs +++ b/crates/cli/src/subcommands/list.rs @@ -1,10 +1,11 @@ use crate::common_args; use crate::util; use crate::util::get_login_token_or_log_in; +use crate::util::ResponseExt; use crate::util::UNSTABLE_WARNING; use crate::Config; +use anyhow::Context; use clap::{ArgMatches, Command}; -use reqwest::StatusCode; use serde::Deserialize; use spacetimedb::Identity; use tabled::{ @@ -44,7 +45,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let client = reqwest::Client::new(); let res = client .get(format!( - "{}/identity/{}/databases", + "{}/v1/identity/{}/databases", config.get_host_url(server)?, identity )) @@ -52,14 +53,10 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E .send() .await?; - if res.status() != StatusCode::OK { - return Err(anyhow::anyhow!(format!( - "Unable to retrieve databases for identity: {}", - res.status() - ))); - } - - let result: DatabasesResult = res.json().await?; + let result: DatabasesResult = res + .json_or_error() + .await + .context("unable to retrieve databases for identity")?; if !result.identities.is_empty() { let mut table = Table::new(result.identities); diff --git a/crates/cli/src/subcommands/login.rs b/crates/cli/src/subcommands/login.rs index 511fc79e41..8e2568b478 100644 --- a/crates/cli/src/subcommands/login.rs +++ b/crates/cli/src/subcommands/login.rs @@ -273,6 +273,12 @@ struct LocalLoginResponse { async fn spacetimedb_direct_login(host: &Url) -> Result { let client = reqwest::Client::new(); - let response: LocalLoginResponse = client.post(host.join("identity")?).send().await?.json().await?; + let response: LocalLoginResponse = client + .post(host.join("/v1/identity")?) + .send() + .await? + .error_for_status()? + .json() + .await?; Ok(response.token) } diff --git a/crates/cli/src/subcommands/logs.rs b/crates/cli/src/subcommands/logs.rs index 8a3c7f3d4d..c92a6f01cd 100644 --- a/crates/cli/src/subcommands/logs.rs +++ b/crates/cli/src/subcommands/logs.rs @@ -130,7 +130,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let host_url = config.get_host_url(server)?; - let builder = reqwest::Client::new().get(format!("{}/database/logs/{}", host_url, database_identity)); + let builder = reqwest::Client::new().get(format!("{}/v1/database/{}/logs", host_url, database_identity)); let builder = add_auth_header_opt(builder, &auth_header); let mut res = builder.query(&query_parms).send().await?; let status = res.status(); diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 20b904afd3..d5ce16efaf 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -1,4 +1,3 @@ -use anyhow::bail; use clap::Arg; use clap::ArgAction::{Set, SetTrue}; use clap::ArgMatches; @@ -9,7 +8,7 @@ use std::fs; use std::path::PathBuf; use crate::config::Config; -use crate::util::{add_auth_header_opt, get_auth_header}; +use crate::util::{add_auth_header_opt, get_auth_header, ResponseExt}; use crate::util::{decode_identity, unauth_error_context, y_or_n}; use crate::{build, common_args}; @@ -82,18 +81,19 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // easily create a new identity with an email let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; - let mut query_params = Vec::<(&str, &str)>::new(); - query_params.push(("host_type", "wasm")); - query_params.push(("register_tld", "true")); + let client = reqwest::Client::new(); // If a domain or identity was provided, we should locally make sure it looks correct and - // append it as a query parameter - if let Some(name_or_identity) = name_or_identity { + let mut builder = if let Some(name_or_identity) = name_or_identity { if !is_identity(name_or_identity) { parse_domain_name(name_or_identity)?; } - query_params.push(("name_or_identity", name_or_identity.as_str())); - } + let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') }; + let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set); + client.put(format!("{database_host}/v1/database/{domain}")) + } else { + client.post(format!("{database_host}/v1/database")) + }; if !path_to_project.exists() { return Err(anyhow::anyhow!( @@ -145,16 +145,11 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E println!("Aborting"); return Ok(()); } - query_params.push(("clear", "true")); + builder = builder.query(&[("clear", true)]); } println!("Publishing module..."); - let mut builder = reqwest::Client::new().post(Url::parse_with_params( - format!("{}/database/publish", database_host).as_str(), - query_params, - )?); - builder = add_auth_header_opt(builder, &auth_header); let res = builder.body(program_bytes).send().await?; @@ -169,13 +164,8 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E config.server_nick_or_host(server)?, ); } - if res.status().is_client_error() || res.status().is_server_error() { - let err = res.text().await?; - bail!(err) - } - let bytes = res.bytes().await.unwrap(); - let response: PublishResult = serde_json::from_slice(&bytes[..]).unwrap(); + let response: PublishResult = res.json_or_error().await?; match response { PublishResult::Success { domain, @@ -192,20 +182,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E println!("{} database with identity: {}", op, database_identity); } } - PublishResult::TldNotRegistered { domain } => { - return Err(anyhow::anyhow!( - "The top level domain that you provided is not registered.\n\ - This tld is not yet registered to any identity: {}", - domain.tld() - )); - } - PublishResult::PermissionDenied { domain } => { + PublishResult::PermissionDenied { name } => { if anon_identity { - anyhow::bail!( - "You need to be logged in as the owner of {} to publish to {}", - domain.tld(), - domain.tld() - ); + anyhow::bail!("You need to be logged in as the owner of {name} to publish to {name}",); } // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap. let token = config.spacetimedb_token().unwrap(); @@ -213,24 +192,11 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E //TODO(jdetter): Have a nice name generator here, instead of using some abstract characters // we should perhaps generate fun names like 'green-fire-dragon' instead let suggested_tld: String = identity.chars().take(12).collect(); - if let Some(sub_domain) = domain.sub_domain() { - return Err(anyhow::anyhow!( - "The top level domain {} is not registered to the identity you provided.\n\ - We suggest you publish to a domain that starts with a TLD owned by you, or publish to a new domain like:\n\ - \tspacetime publish {}/{}\n", - domain.tld(), - suggested_tld, - sub_domain - )); - } else { - return Err(anyhow::anyhow!( - "The top level domain {} is not registered to the identity you provided.\n\ + return Err(anyhow::anyhow!( + "The database {name} is not registered to the identity you provided.\n\ We suggest you push to either a domain owned by you, or a new domain like:\n\ - \tspacetime publish {}\n", - domain.tld(), - suggested_tld - )); - } + \tspacetime publish {suggested_tld}\n", + )); } } diff --git a/crates/cli/src/subcommands/server.rs b/crates/cli/src/subcommands/server.rs index be1af47312..77e0e9b9ae 100644 --- a/crates/cli/src/subcommands/server.rs +++ b/crates/cli/src/subcommands/server.rs @@ -292,7 +292,7 @@ pub async fn exec_ping(config: Config, args: &ArgMatches) -> Result<(), anyhow:: let server = args.get_one::("server").unwrap().as_str(); let url = config.get_host_url(Some(server))?; - let builder = reqwest::Client::new().get(format!("{}/database/ping", url).as_str()); + let builder = reqwest::Client::new().get(format!("{}/v1/ping", url).as_str()); let response = builder.send().await?; match response.status() { diff --git a/crates/cli/src/subcommands/sql.rs b/crates/cli/src/subcommands/sql.rs index 6c546c0e9e..6024ac099e 100644 --- a/crates/cli/src/subcommands/sql.rs +++ b/crates/cli/src/subcommands/sql.rs @@ -2,6 +2,7 @@ use std::time::Instant; use crate::api::{from_json_seed, ClientApi, Connection, StmtResultJson}; use crate::common_args; +use anyhow::Context; use clap::{Arg, ArgAction, ArgMatches}; use itertools::Itertools; use reqwest::RequestBuilder; @@ -10,8 +11,7 @@ use spacetimedb_lib::sats::{satn, Typespace}; use tabled::settings::Style; use crate::config::Config; -use crate::errors::error_for_status; -use crate::util::{database_identity, get_auth_header, UNSTABLE_WARNING}; +use crate::util::{database_identity, get_auth_header, ResponseExt, UNSTABLE_WARNING}; pub fn cli() -> clap::Command { clap::Command::new("sql") @@ -67,12 +67,16 @@ fn print_timings(now: Instant) { pub(crate) async fn run_sql(builder: RequestBuilder, sql: &str, with_stats: bool) -> Result<(), anyhow::Error> { let now = Instant::now(); - let json = error_for_status(builder.body(sql.to_owned()).send().await?) + let json = builder + .body(sql.to_owned()) + .send() + .await? + .ensure_content_type("application/json") .await? .text() .await?; - let stmt_result_json: Vec = serde_json::from_str(&json)?; + let stmt_result_json: Vec = serde_json::from_str(&json).context("malformed sql response")?; // Print only `OK for empty tables as it's likely a command like `INSERT`. if stmt_result_json.is_empty() { diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 50ab3be7f7..491c71b7a9 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -2,7 +2,7 @@ use anyhow::Context; use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE_64_STD_NO_PAD, Engine as _}; use reqwest::{RequestBuilder, Url}; use spacetimedb::auth::identity::{IncomingClaims, SpacetimeIdentityClaims}; -use spacetimedb_client_api_messages::name::{DnsLookupResponse, RegisterTldResult, ReverseDNSResponse}; +use spacetimedb_client_api_messages::name::GetNamesResponse; use spacetimedb_lib::Identity; use std::io::Write; use std::path::Path; @@ -21,9 +21,88 @@ pub async fn database_identity( if let Ok(identity) = Identity::from_hex(name_or_identity) { return Ok(identity); } - match spacetime_dns(config, name_or_identity, server).await? { - DnsLookupResponse::Success { domain: _, identity } => Ok(identity), - DnsLookupResponse::Failure { domain } => Err(anyhow::anyhow!("The dns resolution of `{}` failed.", domain)), + spacetime_dns(config, name_or_identity, server) + .await? + .with_context(|| format!("the dns resolution of `{name_or_identity}` failed.")) +} + +pub(crate) trait ResponseExt: Sized { + /// Ensure that this response has the given content-type, especially if it's + /// a success response. + /// + /// This checks the response status for you, so you shouldn't call + /// `error_for_status()` beforehand. + /// + /// If the response does not have the given content type, assume it's an error message + /// and return it as such. Success responses with the wrong content type are treated + /// as a bug in the API implementation, since that makes it harder to tell what's + /// meant to be a structured response and what's a plain-text error message. + async fn ensure_content_type(self, content_type: &str) -> anyhow::Result; + + /// Like [`reqwest::Response::json()`], but handles non-JSON error messages gracefully. + async fn json_or_error(self) -> anyhow::Result; + + /// Transforms a status of `NOT_FOUND` into `None`. + fn found(self) -> Option; +} + +fn err_status_desc(status: http::StatusCode) -> Option<&'static str> { + if status.is_success() { + None + } else if status.is_client_error() { + Some("HTTP status client error") + } else if status.is_server_error() { + Some("HTTP status server error") + } else { + Some("unexpected HTTP status code") + } +} + +impl ResponseExt for reqwest::Response { + async fn ensure_content_type(self, content_type: &str) -> anyhow::Result { + let status = self.status(); + if self + .headers() + .get(http::header::CONTENT_TYPE) + .is_some_and(|ty| ty == content_type) + { + return Ok(self); + } + let url = self.url(); + let Some(status_desc) = err_status_desc(status) else { + anyhow::bail!("HTTP response from url ({url}) was success but did not have content-type: {content_type}"); + }; + let url = url.to_string(); + let status_err = match self.error_for_status_ref() { + Err(e) => anyhow::Error::from(e), + Ok(_) => anyhow::anyhow!("{status_desc} ({status}) from url ({url})"), + }; + let err = match self.text().await { + Ok(text) => status_err.context(text), + Err(err) => anyhow::Error::from(err) + .context(format!("{status_desc} ({status})")) + .context("failed to get response text"), + }; + Err(err) + } + + async fn json_or_error(self) -> anyhow::Result { + let status = self.status(); + self.ensure_content_type("application/json") + .await? + .json() + .await + .map_err(|err| { + let mut err = anyhow::Error::from(err); + if let Some(desc) = err_status_desc(status) { + err = err.context(format!("malformed json payload for {desc} ({status})")) + } + err + }) + } + + fn found(self) -> Option { + (self.status() != http::StatusCode::NOT_FOUND).then_some(self) } } @@ -32,37 +111,20 @@ pub async fn spacetime_dns( config: &Config, domain: &str, server: Option<&str>, -) -> Result { +) -> Result, anyhow::Error> { let client = reqwest::Client::new(); - let url = format!("{}/database/dns/{}", config.get_host_url(server)?, domain); - let res = client.get(url).send().await?.error_for_status()?; - let bytes = res.bytes().await.unwrap(); - Ok(serde_json::from_slice(&bytes[..]).unwrap()) -} - -/// Registers the given top level domain to the given identity. If None is passed in as identity, the default -/// identity will be looked up in the config and it will be used instead. Returns Ok() if the -/// domain is successfully registered, returns Err otherwise. -pub async fn spacetime_register_tld( - config: &mut Config, - tld: &str, - server: Option<&str>, - interactive: bool, -) -> Result { - let auth_header = get_auth_header(config, false, server, interactive).await?; - - // TODO(jdetter): Fix URL encoding on specifying this domain - let builder = reqwest::Client::new() - .get(format!("{}/database/register_tld?tld={}", config.get_host_url(server)?, tld).as_str()); - let builder = add_auth_header_opt(builder, &auth_header); - - let res = builder.send().await?.error_for_status()?; - let bytes = res.bytes().await.unwrap(); - Ok(serde_json::from_slice(&bytes[..]).unwrap()) + let url = format!("{}/v1/database/{}/identity", config.get_host_url(server)?, domain); + let Some(res) = client.get(url).send().await?.found() else { + return Ok(None); + }; + let text = res.error_for_status()?.text().await?; + text.parse() + .map(Some) + .context("identity endpoint did not return an identity") } pub async fn spacetime_server_fingerprint(url: &str) -> anyhow::Result { - let builder = reqwest::Client::new().get(format!("{}/identity/public-key", url).as_str()); + let builder = reqwest::Client::new().get(format!("{}/v1/identity/public-key", url).as_str()); let res = builder.send().await?.error_for_status()?; let fingerprint = res.text().await?; Ok(fingerprint) @@ -73,12 +135,10 @@ pub async fn spacetime_reverse_dns( config: &Config, identity: &str, server: Option<&str>, -) -> Result { +) -> Result { let client = reqwest::Client::new(); - let url = format!("{}/database/reverse_dns/{}", config.get_host_url(server)?, identity); - let res = client.get(url).send().await?.error_for_status()?; - let bytes = res.bytes().await.unwrap(); - Ok(serde_json::from_slice(&bytes[..]).unwrap()) + let url = format!("{}/v1/database/{}/names", config.get_host_url(server)?, identity); + client.get(url).send().await?.json_or_error().await } /// Add an authorization header, if provided, to the request `builder`. diff --git a/crates/cli/tests/snapshots/codegen__codegen_typescript.snap b/crates/cli/tests/snapshots/codegen__codegen_typescript.snap index fe99e86ec4..dd7fd6f058 100644 --- a/crates/cli/tests/snapshots/codegen__codegen_typescript.snap +++ b/crates/cli/tests/snapshots/codegen__codegen_typescript.snap @@ -16,9 +16,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -79,9 +79,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -142,9 +142,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -207,9 +207,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -267,9 +267,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -330,9 +330,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -390,9 +390,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -453,9 +453,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -516,9 +516,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -594,9 +594,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -672,9 +672,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -737,9 +737,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -962,23 +962,23 @@ const REMOTE_MODULE = { argsType: TestBtreeIndexArgs.getTypeScriptAlgebraicType(), }, }, - // Constructors which are used by the DBConnectionImpl to + // Constructors which are used by the DbConnectionImpl to // extract type information from the generated RemoteModule. // // NOTE: This is not strictly necessary for `eventContextConstructor` because // all we do is build a TypeScript object which we could have done inside the // SDK, but if in the future we wanted to create a class this would be // necessary because classes have methods, so we'll keep it. - eventContextConstructor: (imp: DBConnectionImpl, event: Event) => { + eventContextConstructor: (imp: DbConnectionImpl, event: Event) => { return { - ...(imp as DBConnection), + ...(imp as DbConnection), event } }, - dbViewConstructor: (imp: DBConnectionImpl) => { + dbViewConstructor: (imp: DbConnectionImpl) => { return new RemoteTables(imp); }, - reducersConstructor: (imp: DBConnectionImpl, setReducerFlags: SetReducerFlags) => { + reducersConstructor: (imp: DbConnectionImpl, setReducerFlags: SetReducerFlags) => { return new RemoteReducers(imp, setReducerFlags); }, setReducerFlagsConstructor: () => { @@ -1005,7 +1005,7 @@ export type Reducer = never ; export class RemoteReducers { - constructor(private connection: DBConnectionImpl, private setCallReducerFlags: SetReducerFlags) {} + constructor(private connection: DbConnectionImpl, private setCallReducerFlags: SetReducerFlags) {} add(name: string, age: number) { const __args = { name, age }; @@ -1274,7 +1274,7 @@ export class SetReducerFlags { } export class RemoteTables { - constructor(private connection: DBConnectionImpl) {} + constructor(private connection: DbConnectionImpl) {} get hasSpecialStuff(): HasSpecialStuffTableHandle { return new HasSpecialStuffTableHandle(this.connection.clientCache.getOrCreateTable(REMOTE_MODULE.tables.has_special_stuff)); @@ -1327,8 +1327,8 @@ export class RemoteTables { export class SubscriptionBuilder extends SubscriptionBuilderImpl { } -export class DBConnection extends DBConnectionImpl { - static builder = (): DBConnectionBuilder => { +export class DbConnection extends DbConnectionImpl { + static builder = (): DbConnectionBuilder => { return new DBConnectionBuilder(REMOTE_MODULE, (imp: DBConnectionImpl) => imp as DBConnection); } subscriptionBuilder = (): SubscriptionBuilder => { @@ -1355,9 +1355,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1418,9 +1418,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1478,9 +1478,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1630,9 +1630,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1703,9 +1703,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1779,9 +1779,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1887,9 +1887,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -1954,9 +1954,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2084,9 +2084,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2149,9 +2149,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2301,9 +2301,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2368,9 +2368,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2433,9 +2433,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2511,9 +2511,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2589,9 +2589,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2652,9 +2652,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2712,9 +2712,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2820,9 +2820,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2887,9 +2887,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -2952,9 +2952,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3012,9 +3012,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3090,9 +3090,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3157,9 +3157,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3220,9 +3220,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3280,9 +3280,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3360,9 +3360,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3425,9 +3425,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3533,9 +3533,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3598,9 +3598,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3678,9 +3678,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, @@ -3743,9 +3743,9 @@ import { BinaryWriter, CallReducerFlags, ConnectionId, - DBConnectionBuilder, - DBConnectionImpl, - DBContext, + DbConnectionBuilder, + DbConnectionImpl, + DbContext, ErrorContextInterface, Event, EventContextInterface, diff --git a/crates/client-api-messages/Cargo.toml b/crates/client-api-messages/Cargo.toml index 141a8514ab..90f767ee36 100644 --- a/crates/client-api-messages/Cargo.toml +++ b/crates/client-api-messages/Cargo.toml @@ -18,6 +18,7 @@ enum-as-inner.workspace = true flate2.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +serde_with.workspace = true smallvec.workspace = true strum.workspace = true thiserror.workspace = true diff --git a/crates/client-api-messages/src/name.rs b/crates/client-api-messages/src/name.rs index 0678992303..ecd9ef9b42 100644 --- a/crates/client-api-messages/src/name.rs +++ b/crates/client-api-messages/src/name.rs @@ -50,7 +50,7 @@ pub enum PublishResult { /// In other words, this echoes back a domain name if one was given. If /// the database name given was in fact a database identity, this will be /// `None`. - domain: Option, + domain: Option, /// The identity of the published database. /// /// Always set, regardless of whether publish resolved a domain name first @@ -59,14 +59,6 @@ pub enum PublishResult { op: PublishOp, }, - // TODO: below variants are obsolete with control db module - /// The top level domain for the database name is not registered. For example: - /// - /// - `clockworklabs/bitcraft` - /// - /// if `clockworklabs` is not registered, this error is returned. - TldNotRegistered { domain: DomainName }, - /// The top level domain for the database name is registered, but the identity that you provided does /// not have permission to insert the given database name. For example: /// @@ -75,7 +67,7 @@ pub enum PublishResult { /// If you were trying to insert this database name, but the tld `clockworklabs` is /// owned by an identity other than the identity that you provided, then you will receive /// this error. - PermissionDenied { domain: DomainName }, + PermissionDenied { name: DatabaseName }, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -118,6 +110,106 @@ pub enum SetDefaultDomainResult { }, } +/// A simplified version of [`DomainName`] that allows a limited set of characters. +/// +/// Must match the regex `^[a-z0-9]+(-[a-z0-9]+)*$` +#[derive(Clone, Debug, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] +pub struct DatabaseName(String); + +impl AsRef for DatabaseName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(name: DatabaseName) -> Self { + name.0 + } +} + +impl fmt::Display for DatabaseName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(thiserror::Error, Clone, Copy, Debug)] +pub enum DatabaseNameError { + #[error("database names cannot be identities")] + Identity, + #[error("database names cannot be empty")] + Empty, + #[error("invalid hyphen in database name")] + Hyphen, + #[error("invalid characters in database name")] + Invalid, +} + +fn parse_database_name(s: &str) -> Result<&str, DatabaseNameError> { + use DatabaseNameError::*; + + if is_identity(s) { + return Err(Identity); + } + + let mut chrs = s.chars(); + let mut next = || chrs.next(); + + let is_az09 = |c: char| matches!(c, 'a'..='z' | '0'..='9'); + + let c = next().ok_or(Empty)?; + if c == '-' { + return Err(Hyphen); + } else if !is_az09(c) { + return Err(Invalid); + } + + while let Some(c) = next() { + if c == '-' { + // can't have a hyphen at the end + let c = next().ok_or(Hyphen)?; + // can't have 2 hyphens in a row + if !is_az09(c) { + return Err(Hyphen); + } + } else if !is_az09(c) { + return Err(Invalid); + } + } + + Ok(s) +} + +impl TryFrom for DatabaseName { + type Error = DatabaseNameError; + + fn try_from(s: String) -> Result { + parse_database_name(&s)?; + Ok(Self(s)) + } +} + +impl FromStr for DatabaseName { + type Err = DatabaseNameError; + + fn from_str(s: &str) -> Result { + Ok(Self(parse_database_name(s)?.to_owned())) + } +} + +impl From for Tld { + fn from(name: DatabaseName) -> Self { + Tld(name.0) + } +} + +impl From for DomainName { + fn from(name: DatabaseName) -> Self { + Tld::from(name).into() + } +} + /// The top level domain part of a [`DomainName`]. /// /// This newtype witnesses that the TLD is well-formed as per the parsing rules @@ -317,6 +409,12 @@ impl AsRef for DomainName { } } +impl From for String { + fn from(name: DomainName) -> Self { + name.domain_name + } +} + impl fmt::Display for DomainName { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(&self.domain_name) @@ -420,8 +518,8 @@ mod serde_impls { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ReverseDNSResponse { - pub names: Vec, +pub struct GetNamesResponse { + pub names: Vec, } /// Returns whether a hex string is a valid identity. diff --git a/crates/client-api/Cargo.toml b/crates/client-api/Cargo.toml index bff171245d..368bbe142f 100644 --- a/crates/client-api/Cargo.toml +++ b/crates/client-api/Cargo.toml @@ -34,6 +34,9 @@ http.workspace = true headers.workspace = true mime = "0.3.17" tokio-stream = { version = "0.1.12", features = ["sync"] } +tower-layer.workspace = true +tower-service.workspace = true +tower-http.workspace = true futures = "0.3" bytes = "1" tracing.workspace = true diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index 13b7e04ae1..0cb21d75f0 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -185,7 +185,7 @@ pub trait ControlStateReadAccess { fn get_energy_balance(&self, identity: &Identity) -> anyhow::Result>; // DNS - fn lookup_identity(&self, domain: &DomainName) -> anyhow::Result>; + fn lookup_identity(&self, domain: &str) -> anyhow::Result>; fn reverse_lookup(&self, database_identity: &Identity) -> anyhow::Result>; } @@ -259,7 +259,7 @@ impl ControlStateReadAccess for Arc { } // DNS - fn lookup_identity(&self, domain: &DomainName) -> anyhow::Result> { + fn lookup_identity(&self, domain: &str) -> anyhow::Result> { (**self).lookup_identity(domain) } diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 2feb631de2..5b3cd1ade1 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1,13 +1,14 @@ use crate::auth::{ - anon_auth_middleware, SpacetimeAuth, SpacetimeAuthHeader, SpacetimeEnergyUsed, SpacetimeExecutionDurationMicros, - SpacetimeIdentity, SpacetimeIdentityToken, + anon_auth_middleware, SpacetimeAuth, SpacetimeEnergyUsed, SpacetimeExecutionDurationMicros, SpacetimeIdentity, + SpacetimeIdentityToken, }; use crate::routes::subscribe::generate_random_connection_id; use crate::util::{ByteStringBody, NameOrIdentity}; use crate::{log_and_500, ControlStateDelegate, DatabaseDef, NodeDelegate}; use axum::body::{Body, Bytes}; -use axum::extract::{DefaultBodyLimit, Path, Query, State}; +use axum::extract::{Path, Query, State}; use axum::response::{ErrorResponse, IntoResponse}; +use axum::routing::MethodRouter; use axum::Extension; use axum_extra::TypedHeader; use futures::StreamExt; @@ -20,20 +21,12 @@ use spacetimedb::host::ReducerOutcome; use spacetimedb::host::UpdateDatabaseResult; use spacetimedb::identity::Identity; use spacetimedb::messages::control_db::{Database, HostType}; -use spacetimedb_client_api_messages::name::{self, DnsLookupResponse, DomainName, PublishOp, PublishResult}; +use spacetimedb_client_api_messages::name::{self, DatabaseName, PublishOp, PublishResult}; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::sats; -use super::identity::IdentityForUrl; - -pub(crate) struct DomainParsingRejection; - -impl IntoResponse for DomainParsingRejection { - fn into_response(self) -> axum::response::Response { - (StatusCode::BAD_REQUEST, "Unable to parse domain name").into_response() - } -} +use super::subscribe::handle_websocket; #[derive(Deserialize)] pub struct CallParams { @@ -48,13 +41,17 @@ pub async fn call( name_or_identity, reducer, }): Path, + TypedHeader(content_type): TypedHeader, ByteStringBody(body): ByteStringBody, ) -> axum::response::Result { + if content_type != headers::ContentType::json() { + return Err(axum::extract::rejection::MissingJsonContentType::default().into()); + } let caller_identity = auth.identity; let args = ReducerArgs::Json(body); - let db_identity = name_or_identity.resolve(&worker_ctx).await?.into(); + let db_identity = name_or_identity.resolve(&worker_ctx).await?; let database = worker_ctx_find_database(&worker_ctx, &db_identity) .await? .ok_or_else(|| { @@ -180,7 +177,7 @@ pub async fn schema( where S: ControlStateDelegate + NodeDelegate, { - let db_identity = name_or_identity.resolve(&worker_ctx).await?.into(); + let db_identity = name_or_identity.resolve(&worker_ctx).await?; let database = worker_ctx_find_database(&worker_ctx, &db_identity) .await? .ok_or((StatusCode::NOT_FOUND, "No such database."))?; @@ -236,7 +233,7 @@ pub async fn db_info( Path(DatabaseParam { name_or_identity }): Path, ) -> axum::response::Result { log::trace!("Trying to resolve database identity: {:?}", name_or_identity); - let database_identity = name_or_identity.resolve(&worker_ctx).await?.into(); + let database_identity = name_or_identity.resolve(&worker_ctx).await?; log::trace!("Resolved identity to: {database_identity:?}"); let database = worker_ctx_find_database(&worker_ctx, &database_identity) .await? @@ -271,7 +268,7 @@ where // You should not be able to read the logs from a database that you do not own // so, unless you are the owner, this will fail. - let database_identity: Identity = name_or_identity.resolve(&worker_ctx).await?.into(); + let database_identity: Identity = name_or_identity.resolve(&worker_ctx).await?; let database = worker_ctx_find_database(&worker_ctx, &database_identity) .await? .ok_or((StatusCode::NOT_FOUND, "No such database."))?; @@ -373,7 +370,7 @@ where // Anyone is authorized to execute SQL queries. The SQL engine will determine // which queries this identity is allowed to execute against the database. - let db_identity = name_or_identity.resolve(&worker_ctx).await?.into(); + let db_identity = name_or_identity.resolve(&worker_ctx).await?; let database = worker_ctx_find_database(&worker_ctx, &db_identity) .await? .ok_or((StatusCode::NOT_FOUND, "No such database."))?; @@ -393,109 +390,97 @@ where #[derive(Deserialize)] pub struct DNSParams { - database_name: String, + name_or_identity: NameOrIdentity, } #[derive(Deserialize)] pub struct ReverseDNSParams { - database_identity: IdentityForUrl, + name_or_identity: NameOrIdentity, } #[derive(Deserialize)] pub struct DNSQueryParams {} -pub async fn dns( +pub async fn get_identity( State(ctx): State, - Path(DNSParams { database_name }): Path, + Path(DNSParams { name_or_identity }): Path, Query(DNSQueryParams {}): Query, ) -> axum::response::Result { - let domain = database_name.parse().map_err(|_| DomainParsingRejection)?; - let db_identity = ctx.lookup_identity(&domain).map_err(log_and_500)?; - let response = if let Some(db_identity) = db_identity { - DnsLookupResponse::Success { - domain, - identity: db_identity, - } - } else { - DnsLookupResponse::Failure { domain } - }; - - Ok(axum::Json(response)) + let identity = name_or_identity.resolve(&ctx).await?; + Ok(identity.to_string()) } -pub async fn reverse_dns( +pub async fn get_names( State(ctx): State, - Path(ReverseDNSParams { database_identity }): Path, + Path(ReverseDNSParams { name_or_identity }): Path, ) -> axum::response::Result { - let database_identity = Identity::from(database_identity); + let database_identity = name_or_identity.resolve(&ctx).await?; - let names = ctx.reverse_lookup(&database_identity).map_err(log_and_500)?; + let names = ctx + .reverse_lookup(&database_identity) + .map_err(log_and_500)? + .into_iter() + .filter_map(|x| String::from(x).try_into().ok()) + .collect(); - let response = name::ReverseDNSResponse { names }; + let response = name::GetNamesResponse { names }; Ok(axum::Json(response)) } #[derive(Deserialize)] -pub struct RegisterTldParams { - tld: String, +pub struct PublishDatabaseParams { + name_or_identity: Option, } -pub async fn register_tld( - State(ctx): State, - Query(RegisterTldParams { tld }): Query, - Extension(auth): Extension, -) -> axum::response::Result { - // You should not be able to publish to a database that you do not own - // so, unless you are the owner, this will fail, hence not using get_or_create - - let tld = tld.parse::().map_err(|_| DomainParsingRejection)?.into(); - let result = ctx.register_tld(&auth.identity, tld).await.map_err(log_and_500)?; - Ok(axum::Json(result)) -} - -#[derive(Deserialize)] -pub struct PublishDatabaseParams {} - #[derive(Deserialize)] pub struct PublishDatabaseQueryParams { #[serde(default)] clear: bool, - name_or_identity: Option, -} - -impl PublishDatabaseQueryParams { - pub fn name_or_identity(&self) -> Option<&NameOrIdentity> { - self.name_or_identity.as_ref() - } } pub async fn publish( State(ctx): State, - Path(PublishDatabaseParams {}): Path, - Query(query_params): Query, + Path(PublishDatabaseParams { name_or_identity }): Path, + Query(PublishDatabaseQueryParams { clear }): Query, Extension(auth): Extension, body: Bytes, ) -> axum::response::Result> { - let PublishDatabaseQueryParams { - name_or_identity, - clear, - } = query_params; - // You should not be able to publish to a database that you do not own // so, unless you are the owner, this will fail. - let (database_identity, db_name) = match name_or_identity { + let (database_identity, db_name) = match &name_or_identity { Some(noa) => match noa.try_resolve(&ctx).await? { - Ok(resolved) => resolved.into(), - Err(domain) => { + Ok(resolved) => (resolved, noa.name()), + Err(name) => { // `name_or_identity` was a `NameOrIdentity::Name`, but no record // exists yet. Create it now with a fresh identity. let database_auth = SpacetimeAuth::alloc(&ctx).await?; let database_identity = database_auth.identity; - ctx.create_dns_record(&auth.identity, &domain, &database_identity) + let tld: name::Tld = name.clone().into(); + let tld = match ctx.register_tld(&auth.identity, tld).await.map_err(log_and_500)? { + name::RegisterTldResult::Success { domain } + | name::RegisterTldResult::AlreadyRegistered { domain } => domain, + name::RegisterTldResult::Unauthorized { .. } => { + return Err(( + StatusCode::UNAUTHORIZED, + axum::Json(PublishResult::PermissionDenied { name: name.clone() }), + ) + .into()) + } + }; + let res = ctx + .create_dns_record(&auth.identity, &tld.into(), &database_identity) .await .map_err(log_and_500)?; - (database_identity, Some(domain)) + match res { + name::InsertDomainResult::Success { .. } => {} + name::InsertDomainResult::TldNotRegistered { .. } + | name::InsertDomainResult::PermissionDenied { .. } => { + return Err(log_and_500("impossible: we just registered the tld")) + } + name::InsertDomainResult::OtherError(e) => return Err(log_and_500(e)), + } + (database_identity, Some(name)) } }, None => { @@ -556,7 +541,7 @@ pub async fn publish( } Ok(axum::Json(PublishResult::Success { - domain: db_name.as_ref().map(ToString::to_string), + domain: db_name.cloned(), database_identity, op, })) @@ -564,15 +549,15 @@ pub async fn publish( #[derive(Deserialize)] pub struct DeleteDatabaseParams { - database_identity: IdentityForUrl, + name_or_identity: NameOrIdentity, } pub async fn delete_database( State(ctx): State, - Path(DeleteDatabaseParams { database_identity }): Path, + Path(DeleteDatabaseParams { name_or_identity }): Path, Extension(auth): Extension, ) -> axum::response::Result { - let database_identity = Identity::from(database_identity); + let database_identity = name_or_identity.resolve(&ctx).await?; ctx.delete_database(&auth.identity, &database_identity) .await @@ -582,76 +567,107 @@ pub async fn delete_database( } #[derive(Deserialize)] -pub struct SetNameQueryParams { - domain: String, - database_identity: IdentityForUrl, +pub struct AddNameParams { + name_or_identity: NameOrIdentity, } -pub async fn set_name( +pub async fn add_name( State(ctx): State, - Query(SetNameQueryParams { - domain, - database_identity, - }): Query, + Path(AddNameParams { name_or_identity }): Path, Extension(auth): Extension, + name: String, ) -> axum::response::Result { - let database_identity = Identity::from(database_identity); + let name = DatabaseName::try_from(name).map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))?; + let database_identity = name_or_identity.resolve(&ctx).await?; - let database = ctx - .get_database_by_identity(&database_identity) - .map_err(log_and_500)? - .ok_or((StatusCode::NOT_FOUND, "No such database."))?; - - if database.owner_identity != auth.identity { - return Err((StatusCode::UNAUTHORIZED, "Identity does not own database.").into()); - } - - let domain = domain.parse().map_err(|_| DomainParsingRejection)?; let response = ctx - .create_dns_record(&auth.identity, &domain, &database_identity) + .create_dns_record(&auth.identity, &name.into(), &database_identity) .await // TODO: better error code handling .map_err(log_and_500)?; - Ok(axum::Json(response)) + let code = match response { + name::InsertDomainResult::Success { .. } => StatusCode::OK, + name::InsertDomainResult::TldNotRegistered { .. } => StatusCode::BAD_REQUEST, + name::InsertDomainResult::PermissionDenied { .. } => StatusCode::UNAUTHORIZED, + name::InsertDomainResult::OtherError(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + + Ok((code, axum::Json(response))) } -/// This API call is just designed to allow clients to determine whether or not they can -/// establish a connection to SpacetimeDB. This API call doesn't actually do anything. -pub async fn ping(State(_ctx): State, _auth: SpacetimeAuthHeader) -> axum::response::Result { - Ok(()) +/// This struct allows the edition to customize `/database` routes more meticulously. +pub struct DatabaseRoutes { + /// POST /database + pub root_post: MethodRouter, + /// PUT: /database/:name_or_identity + pub db_put: MethodRouter, + /// GET: /database/:name_or_identity + pub db_get: MethodRouter, + /// DELETE: /database/:name_or_identity + pub db_delete: MethodRouter, + /// GET: /database/:name_or_identity/names + pub names_get: MethodRouter, + /// POST: /database/:name_or_identity/names + pub names_post: MethodRouter, + /// GET: /database/:name_or_identity/identity + pub identity_get: MethodRouter, + /// GET: /database/:name_or_identity/subscribe + pub subscribe_get: MethodRouter, + /// POST: /database/:name_or_identity/call/:reducer + pub call_reducer_post: MethodRouter, + /// GET: /database/:name_or_identity/schema + pub schema_get: MethodRouter, + /// GET: /database/:name_or_identity/logs + pub logs_get: MethodRouter, + /// POST: /database/:name_or_identity/sql + pub sql_post: MethodRouter, } -pub fn control_routes(ctx: S) -> axum::Router +impl Default for DatabaseRoutes where S: NodeDelegate + ControlStateDelegate + Clone + 'static, { - use axum::routing::{get, post}; - axum::Router::new() - .route("/dns/:database_name", get(dns::)) - .route("/reverse_dns/:database_identity", get(reverse_dns::)) - .route("/set_name", get(set_name::)) - .route("/ping", get(ping::)) - .route("/register_tld", get(register_tld::)) - .route("/publish", post(publish::).layer(DefaultBodyLimit::disable())) - .route("/delete/:database_identity", post(delete_database::)) - .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)) + fn default() -> Self { + use axum::routing::{delete, get, post, put}; + Self { + root_post: post(publish::), + db_put: put(publish::), + db_get: get(db_info::), + db_delete: delete(delete_database::), + names_get: get(get_names::), + names_post: post(add_name::), + identity_get: get(get_identity::), + subscribe_get: get(handle_websocket::), + call_reducer_post: post(call::), + schema_get: get(schema::), + logs_get: get(logs::), + sql_post: post(sql::), + } + } } -pub fn worker_routes(ctx: S) -> axum::Router +impl DatabaseRoutes where S: NodeDelegate + ControlStateDelegate + Clone + 'static, { - use axum::routing::{get, post}; - axum::Router::new() - .route("/:name_or_identity", get(db_info::)) - .route( - "/subscribe/:name_or_identity", - get(super::subscribe::handle_websocket::), - ) - .route("/call/:name_or_identity/:reducer", post(call::)) - .route("/schema/:name_or_identity", get(schema::)) - .route("/logs/:name_or_identity", get(logs::)) - .route("/sql/:name_or_identity", post(sql::)) - .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)) + pub fn into_router(self, ctx: S) -> axum::Router { + let db_router = axum::Router::::new() + .route("/", self.db_put) + .route("/", self.db_get) + .route("/", self.db_delete) + .route("/names", self.names_get) + .route("/names", self.names_post) + .route("/identity", self.identity_get) + .route("/subscribe", self.subscribe_get) + .route("/call/:reducer", self.call_reducer_post) + .route("/schema", self.schema_get) + .route("/logs", self.logs_get) + .route("/sql", self.sql_post); + + axum::Router::new() + .route("/", self.root_post) + .nest("/:name_or_identity", db_router) + .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)) + } } diff --git a/crates/client-api/src/routes/energy.rs b/crates/client-api/src/routes/energy.rs index 43fe7f5378..15c22560d9 100644 --- a/crates/client-api/src/routes/energy.rs +++ b/crates/client-api/src/routes/energy.rs @@ -124,11 +124,10 @@ where S: NodeDelegate + ControlStateDelegate + Clone + 'static, { use axum::routing::get; - // TODO: rework this. probably no path param. axum::Router::new().route( "/:identity", get(get_energy_balance::) - .post(set_energy_balance::) - .put(add_energy::), + .put(set_energy_balance::) + .post(add_energy::), ) } diff --git a/crates/client-api/src/routes/health.rs b/crates/client-api/src/routes/health.rs index 73e2ceacfa..bf55926a66 100644 --- a/crates/client-api/src/routes/health.rs +++ b/crates/client-api/src/routes/health.rs @@ -29,13 +29,12 @@ pub async fn health( .map(|n| n.unschedulable) .unwrap_or(false); - Ok(serde_json::json!({ + Ok(axum::Json(serde_json::json!({ "package_name": PACKAGE_NAME, "version": VERSION, "nodes": nodes, "schedulable": schedulable, - }) - .to_string()) + }))) } pub fn router() -> axum::Router diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index e8842155f1..e423267a8d 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -135,7 +135,7 @@ pub async fn get_public_key(State(ctx): State) -> axum::resp )) } -pub fn router(_: S) -> axum::Router +pub fn router() -> axum::Router where S: NodeDelegate + ControlStateDelegate + Clone + 'static, { @@ -143,7 +143,7 @@ where axum::Router::new() .route("/", post(create_identity::)) .route("/public-key", get(get_public_key::)) - .route("/websocket_token", post(create_websocket_token::)) + .route("/websocket-token", post(create_websocket_token::)) .route("/:identity/verify", get(validate_token)) .route("/:identity/databases", get(get_databases::)) } diff --git a/crates/client-api/src/routes/mod.rs b/crates/client-api/src/routes/mod.rs index 4f50776a87..fa0925760e 100644 --- a/crates/client-api/src/routes/mod.rs +++ b/crates/client-api/src/routes/mod.rs @@ -1,3 +1,9 @@ +use database::DatabaseRoutes; +use http::header; +use tower_http::cors; + +use crate::{ControlStateDelegate, NodeDelegate}; + pub mod database; pub mod energy; pub mod health; @@ -5,3 +11,30 @@ pub mod identity; pub mod metrics; pub mod prometheus; pub mod subscribe; + +/// This API call is just designed to allow clients to determine whether or not they can +/// establish a connection to SpacetimeDB. This API call doesn't actually do anything. +pub async fn ping(_auth: crate::auth::SpacetimeAuthHeader) {} + +#[allow(clippy::let_and_return)] +pub fn router(ctx: &S, database_routes: DatabaseRoutes, extra: axum::Router) -> axum::Router +where + S: NodeDelegate + ControlStateDelegate + Clone + 'static, +{ + use axum::routing::get; + let router = axum::Router::new() + .nest("/database", database_routes.into_router(ctx.clone())) + .nest("/identity", identity::router()) + .nest("/energy", energy::router()) + .nest("/prometheus", prometheus::router()) + .nest("/metrics", metrics::router()) + .route("/ping", get(ping)) + .merge(extra); + + let cors = cors::CorsLayer::new() + .allow_headers([header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE]) + .allow_methods(cors::Any) + .allow_origin(cors::Any); + + axum::Router::new().nest("/v1", router.layer(cors)) +} diff --git a/crates/client-api/src/routes/subscribe.rs b/crates/client-api/src/routes/subscribe.rs index 1d4033cefc..d017521e43 100644 --- a/crates/client-api/src/routes/subscribe.rs +++ b/crates/client-api/src/routes/subscribe.rs @@ -85,7 +85,7 @@ where ))?; } - let db_identity = name_or_identity.resolve(&ctx).await?.into(); + let db_identity = name_or_identity.resolve(&ctx).await?; let (res, ws_upgrade, protocol) = ws.select_protocol([(BIN_PROTOCOL, Protocol::Binary), (TEXT_PROTOCOL, Protocol::Text)]); diff --git a/crates/client-api/src/util.rs b/crates/client-api/src/util.rs index 97ece50c68..6c61390c09 100644 --- a/crates/client-api/src/util.rs +++ b/crates/client-api/src/util.rs @@ -8,12 +8,13 @@ use axum::body::Bytes; use axum::extract::{FromRequest, Request}; use axum::response::IntoResponse; use bytestring::ByteString; +use futures::TryStreamExt; use http::{HeaderName, HeaderValue, StatusCode}; +use hyper::body::Body; use spacetimedb::Identity; -use spacetimedb_client_api_messages::name::DomainName; +use spacetimedb_client_api_messages::name::DatabaseName; -use crate::routes::database::DomainParsingRejection; use crate::routes::identity::IdentityForUrl; use crate::{log_and_500, ControlStateReadAccess}; @@ -60,62 +61,52 @@ impl headers::Header for XForwardedFor { #[derive(Clone, Debug)] pub enum NameOrIdentity { Identity(IdentityForUrl), - Name(String), + Name(DatabaseName), } impl NameOrIdentity { pub fn into_string(self) -> String { match self { NameOrIdentity::Identity(addr) => Identity::from(addr).to_hex().to_string(), - NameOrIdentity::Name(name) => name, + NameOrIdentity::Name(name) => name.into(), + } + } + + pub fn name(&self) -> Option<&DatabaseName> { + if let Self::Name(name) = self { + Some(name) + } else { + None } } /// Resolve this [`NameOrIdentity`]. /// /// If `self` is a [`NameOrIdentity::Identity`], the inner [`Identity`] is - /// returned in a [`ResolvedIdentity`] without a [`DomainName`]. + /// returned directly. /// /// Otherwise, if `self` is a [`NameOrIdentity::Name`], the [`Identity`] is - /// looked up by that name in the SpacetimeDB DNS and returned in a - /// [`ResolvedIdentity`] alongside `Some` [`DomainName`]. + /// looked up by that name in the SpacetimeDB DNS and returned. /// - /// Errors are returned if [`NameOrIdentity::Name`] cannot be parsed into a - /// [`DomainName`], or the DNS lookup fails. + /// Errors are returned if [`NameOrIdentity::Name`] the DNS lookup fails. /// - /// An `Ok` result is itself a [`Result`], which is `Err(DomainName)` if the + /// An `Ok` result is itself a [`Result`], which is `Err(DatabaseName)` if the /// given [`NameOrIdentity::Name`] is not registered in the SpacetimeDB DNS, /// i.e. no corresponding [`Identity`] exists. pub async fn try_resolve( &self, ctx: &(impl ControlStateReadAccess + ?Sized), - ) -> axum::response::Result> { + ) -> axum::response::Result> { Ok(match self { - Self::Identity(identity) => Ok(ResolvedIdentity { - identity: Identity::from(*identity), - domain: None, - }), - Self::Name(name) => { - let domain = name.parse().map_err(|_| DomainParsingRejection)?; - let identity = ctx.lookup_identity(&domain).map_err(log_and_500)?; - match identity { - Some(identity) => Ok(ResolvedIdentity { - identity, - domain: Some(domain), - }), - None => Err(domain), - } - } + Self::Identity(identity) => Ok(Identity::from(*identity)), + Self::Name(name) => ctx.lookup_identity(name.as_ref()).map_err(log_and_500)?.ok_or(name), }) } /// A variant of [`Self::try_resolve()`] which maps to a 404 (Not Found) /// response if `self` is a [`NameOrIdentity::Name`] for which no /// corresponding [`Identity`] is found in the SpacetimeDB DNS. - pub async fn resolve( - &self, - ctx: &(impl ControlStateReadAccess + ?Sized), - ) -> axum::response::Result { + pub async fn resolve(&self, ctx: &(impl ControlStateReadAccess + ?Sized)) -> axum::response::Result { self.try_resolve(ctx).await?.map_err(|_| StatusCode::NOT_FOUND.into()) } } @@ -125,13 +116,13 @@ impl<'de> serde::Deserialize<'de> for NameOrIdentity { where D: serde::Deserializer<'de>, { - String::deserialize(deserializer).map(|s| { - if let Ok(addr) = Identity::from_hex(&s) { - NameOrIdentity::Identity(IdentityForUrl::from(addr)) - } else { - NameOrIdentity::Name(s) - } - }) + let s = String::deserialize(deserializer)?; + if let Ok(addr) = Identity::from_hex(&s) { + Ok(NameOrIdentity::Identity(IdentityForUrl::from(addr))) + } else { + let name: DatabaseName = s.try_into().map_err(serde::de::Error::custom)?; + Ok(NameOrIdentity::Name(name)) + } } } @@ -139,37 +130,30 @@ impl fmt::Display for NameOrIdentity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Identity(addr) => f.write_str(addr.into_inner().to_hex().as_str()), - Self::Name(name) => f.write_str(name), + Self::Name(name) => f.write_str(name.as_ref()), } } } -/// A resolved [`NameOrIdentity`]. -/// -/// Constructed by [`NameOrIdentity::try_resolve()`]. -pub struct ResolvedIdentity { - identity: Identity, - domain: Option, -} +pub struct EmptyBody; -impl ResolvedIdentity { - pub fn identity(&self) -> &Identity { - &self.identity - } +#[async_trait::async_trait] +impl FromRequest for EmptyBody { + type Rejection = axum::response::Response; + async fn from_request(req: Request, _state: &S) -> Result { + let body = req.into_body(); + if body.is_end_stream() { + return Ok(Self); + } - pub fn domain(&self) -> Option<&DomainName> { - self.domain.as_ref() - } -} - -impl From for Identity { - fn from(value: ResolvedIdentity) -> Self { - value.identity - } -} - -impl From for (Identity, Option) { - fn from(ResolvedIdentity { identity, domain }: ResolvedIdentity) -> Self { - (identity, domain) + if body + .into_data_stream() + .try_any(|data| futures::future::ready(!data.is_empty())) + .await + .map_err(|_| (StatusCode::BAD_REQUEST, "Failed to buffer the request body").into_response())? + { + return Err((StatusCode::BAD_REQUEST, "body must be empty").into_response()); + } + Ok(Self) } } diff --git a/crates/sdk/src/websocket.rs b/crates/sdk/src/websocket.rs index 1ee557e756..bc462ffc20 100644 --- a/crates/sdk/src/websocket.rs +++ b/crates/sdk/src/websocket.rs @@ -123,8 +123,9 @@ fn make_uri(host: Uri, db_name: &str, connection_id: ConnectionId, params: WsPar path.push('/'); } - path.push_str("database/subscribe/"); + path.push_str("v1/database/"); path.push_str(db_name); + path.push_str("/subscribe"); // Provide the connection ID. path.push_str("?connection_id="); diff --git a/crates/sdk/tests/test-client/src/main.rs b/crates/sdk/tests/test-client/src/main.rs index 844bf86a51..13e5f0e180 100644 --- a/crates/sdk/tests/test-client/src/main.rs +++ b/crates/sdk/tests/test-client/src/main.rs @@ -69,52 +69,52 @@ fn main() { .expect("Pass a test name as a command-line argument to the test client"); match &*test { - "insert_primitive" => exec_insert_primitive(), - "subscribe_and_cancel" => exec_subscribe_and_cancel(), - "subscribe_and_unsubscribe" => exec_subscribe_and_unsubscribe(), - "subscription_error_smoke_test" => exec_subscription_error_smoke_test(), - "delete_primitive" => exec_delete_primitive(), - "update_primitive" => exec_update_primitive(), + "insert-primitive" => exec_insert_primitive(), + "subscribe-and-cancel" => exec_subscribe_and_cancel(), + "subscribe-and-unsubscribe" => exec_subscribe_and_unsubscribe(), + "subscription-error-smoke-test" => exec_subscription_error_smoke_test(), + "delete-primitive" => exec_delete_primitive(), + "update-primitive" => exec_update_primitive(), - "insert_identity" => exec_insert_identity(), - "insert_caller_identity" => exec_insert_caller_identity(), - "delete_identity" => exec_delete_identity(), - "update_identity" => exec_update_identity(), + "insert-identity" => exec_insert_identity(), + "insert-caller-identity" => exec_insert_caller_identity(), + "delete-identity" => exec_delete_identity(), + "update-identity" => exec_update_identity(), - "insert_connection_id" => exec_insert_connection_id(), - "insert_caller_connection_id" => exec_insert_caller_connection_id(), - "delete_connection_id" => exec_delete_connection_id(), - "update_connection_id" => exec_update_connection_id(), + "insert-connection-id" => exec_insert_connection_id(), + "insert-caller-connection-id" => exec_insert_caller_connection_id(), + "delete-connection-id" => exec_delete_connection_id(), + "update-connection-id" => exec_update_connection_id(), - "insert_timestamp" => exec_insert_timestamp(), - "insert_call_timestamp" => exec_insert_call_timestamp(), + "insert-timestamp" => exec_insert_timestamp(), + "insert-call-timestamp" => exec_insert_call_timestamp(), - "on_reducer" => exec_on_reducer(), - "fail_reducer" => exec_fail_reducer(), + "on-reducer" => exec_on_reducer(), + "fail-reducer" => exec_fail_reducer(), - "insert_vec" => exec_insert_vec(), - "insert_option_some" => exec_insert_option_some(), - "insert_option_none" => exec_insert_option_none(), - "insert_struct" => exec_insert_struct(), - "insert_simple_enum" => exec_insert_simple_enum(), - "insert_enum_with_payload" => exec_insert_enum_with_payload(), + "insert-vec" => exec_insert_vec(), + "insert-option-some" => exec_insert_option_some(), + "insert-option-none" => exec_insert_option_none(), + "insert-struct" => exec_insert_struct(), + "insert-simple-enum" => exec_insert_simple_enum(), + "insert-enum-with-payload" => exec_insert_enum_with_payload(), - "insert_delete_large_table" => exec_insert_delete_large_table(), + "insert-delete-large-table" => exec_insert_delete_large_table(), - "insert_primitives_as_strings" => exec_insert_primitives_as_strings(), + "insert-primitives-as-strings" => exec_insert_primitives_as_strings(), // "resubscribe" => exec_resubscribe(), // - "reauth_part_1" => exec_reauth_part_1(), - "reauth_part_2" => exec_reauth_part_2(), + "reauth-part-1" => exec_reauth_part_1(), + "reauth-part-2" => exec_reauth_part_2(), - "should_fail" => exec_should_fail(), + "should-fail" => exec_should_fail(), - "reconnect_same_connection_id" => exec_reconnect_same_connection_id(), - "caller_always_notified" => exec_caller_always_notified(), + "reconnect-same-connection-id" => exec_reconnect_same_connection_id(), + "caller-always-notified" => exec_caller_always_notified(), - "subscribe_all_select_star" => exec_subscribe_all_select_star(), - "caller_alice_receives_reducer_callback_but_not_bob" => { + "subscribe-all-select-star" => exec_subscribe_all_select_star(), + "caller-alice-receives-reducer-callback-but-not-bob" => { exec_caller_alice_receives_reducer_callback_but_not_bob() } _ => panic!("Unknown test: {}", test), diff --git a/crates/sdk/tests/test.rs b/crates/sdk/tests/test.rs index 1932fdd84c..18a559e5e7 100644 --- a/crates/sdk/tests/test.rs +++ b/crates/sdk/tests/test.rs @@ -20,131 +20,131 @@ macro_rules! declare_tests_with_suffix { #[test] fn insert_primitive() { - make_test("insert_primitive").run(); + make_test("insert-primitive").run(); } #[test] fn subscribe_and_cancel() { - make_test("subscribe_and_cancel").run(); + make_test("subscribe-and-cancel").run(); } #[test] fn subscribe_and_unsubscribe() { - make_test("subscribe_and_unsubscribe").run(); + make_test("subscribe-and-unsubscribe").run(); } #[test] fn subscription_error_smoke_test() { - make_test("subscription_error_smoke_test").run(); + make_test("subscription-error-smoke-test").run(); } #[test] fn delete_primitive() { - make_test("delete_primitive").run(); + make_test("delete-primitive").run(); } #[test] fn update_primitive() { - make_test("update_primitive").run(); + make_test("update-primitive").run(); } #[test] fn insert_identity() { - make_test("insert_identity").run(); + make_test("insert-identity").run(); } #[test] fn insert_caller_identity() { - make_test("insert_caller_identity").run(); + make_test("insert-caller-identity").run(); } #[test] fn delete_identity() { - make_test("delete_identity").run(); + make_test("delete-identity").run(); } #[test] fn update_identity() { - make_test("delete_identity").run(); + make_test("delete-identity").run(); } #[test] fn insert_connection_id() { - make_test("insert_connection_id").run(); + make_test("insert-connection-id").run(); } #[test] fn insert_caller_connection_id() { - make_test("insert_caller_connection_id").run(); + make_test("insert-caller-connection-id").run(); } #[test] fn delete_connection_id() { - make_test("delete_connection_id").run(); + make_test("delete-connection-id").run(); } #[test] fn update_connection_id() { - make_test("delete_connection_id").run(); + make_test("delete-connection-id").run(); } #[test] fn insert_timestamp() { - make_test("insert_timestamp").run(); + make_test("insert-timestamp").run(); } #[test] fn insert_call_timestamp() { - make_test("insert_call_timestamp").run(); + make_test("insert-call-timestamp").run(); } #[test] fn on_reducer() { - make_test("on_reducer").run(); + make_test("on-reducer").run(); } #[test] fn fail_reducer() { - make_test("fail_reducer").run(); + make_test("fail-reducer").run(); } #[test] fn insert_vec() { - make_test("insert_vec").run(); + make_test("insert-vec").run(); } #[test] fn insert_option_some() { - make_test("insert_option_some").run(); + make_test("insert-option-some").run(); } #[test] fn insert_option_none() { - make_test("insert_option_none").run(); + make_test("insert-option-none").run(); } #[test] fn insert_struct() { - make_test("insert_struct").run(); + make_test("insert-struct").run(); } #[test] fn insert_simple_enum() { - make_test("insert_simple_enum").run(); + make_test("insert-simple-enum").run(); } #[test] fn insert_enum_with_payload() { - make_test("insert_enum_with_payload").run(); + make_test("insert-enum-with-payload").run(); } #[test] fn insert_delete_large_table() { - make_test("insert_delete_large_table").run(); + make_test("insert-delete-large-table").run(); } #[test] fn insert_primitives_as_strings() { - make_test("insert_primitives_as_strings").run(); + make_test("insert-primitives-as-strings").run(); } // #[test] @@ -155,24 +155,24 @@ macro_rules! declare_tests_with_suffix { #[test] #[should_panic] fn should_fail() { - make_test("should_fail").run(); + make_test("should-fail").run(); } #[test] fn reauth() { - make_test("reauth_part_1").run(); - make_test("reauth_part_2").run(); + make_test("reauth-part-1").run(); + make_test("reauth-part-2").run(); } #[test] fn reconnect_same_connection_id() { - make_test("reconnect_same_connection_id").run(); + make_test("reconnect-same-connection-id").run(); } #[test] fn connect_disconnect_callbacks() { Test::builder() - .with_name(concat!("connect_disconnect_callback_", stringify!($lang))) + .with_name(concat!("connect-disconnect-callback-", stringify!($lang))) .with_module(concat!("sdk-test-connect-disconnect", $suffix)) .with_client(concat!( env!("CARGO_MANIFEST_DIR"), @@ -188,17 +188,17 @@ macro_rules! declare_tests_with_suffix { #[test] fn caller_always_notified() { - make_test("caller_always_notified").run(); + make_test("caller-always-notified").run(); } #[test] fn subscribe_all_select_star() { - make_test("subscribe_all_select_star").run(); + make_test("subscribe-all-select-star").run(); } #[test] fn caller_alice_receives_reducer_callback_but_not_bob() { - make_test("caller_alice_receives_reducer_callback_but_not_bob").run(); + make_test("caller-alice-receives-reducer-callback-but-not-bob").run(); } } }; diff --git a/crates/standalone/src/control_db.rs b/crates/standalone/src/control_db.rs index e672c859d9..5a72a48d56 100644 --- a/crates/standalone/src/control_db.rs +++ b/crates/standalone/src/control_db.rs @@ -78,7 +78,7 @@ impl ControlDb { } impl ControlDb { - pub fn spacetime_dns(&self, domain: &DomainName) -> Result> { + pub fn spacetime_dns(&self, domain: &str) -> Result> { let tree = self.db.open_tree("dns")?; let value = tree.get(domain.to_lowercase().as_bytes())?; if let Some(value) = value { @@ -118,7 +118,7 @@ impl ControlDb { try_register_tld: bool, ) -> Result { let database_identity = *database_identity; - if self.spacetime_dns(&domain)?.is_some() { + if self.spacetime_dns(domain.as_ref())?.is_some() { return Err(Error::RecordAlreadyExists(domain)); } let tld = domain.tld(); diff --git a/crates/standalone/src/control_db/tests.rs b/crates/standalone/src/control_db/tests.rs index b73ddd628a..766c42a099 100644 --- a/crates/standalone/src/control_db/tests.rs +++ b/crates/standalone/src/control_db/tests.rs @@ -67,11 +67,11 @@ fn test_domain() -> anyhow::Result<()> { let tld_owner = cdb.spacetime_lookup_tld(domain.tld())?; assert_eq!(tld_owner, Some(*ALICE)); - let registered_addr = cdb.spacetime_dns(&domain)?; + let registered_addr = cdb.spacetime_dns(domain.as_ref())?; assert_eq!(registered_addr, Some(addr)); // Try lowercase, too - let registered_addr = cdb.spacetime_dns(&domain_lower)?; + let registered_addr = cdb.spacetime_dns(domain_lower.as_ref())?; assert_eq!(registered_addr, Some(addr)); // Reverse should yield the original domain (in mixed-case) diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 67b1b8d8a4..c654af6126 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -1,6 +1,5 @@ mod control_db; mod energy_monitor; -pub mod routes; pub mod subcommands; pub mod util; pub mod version; @@ -208,7 +207,7 @@ impl spacetimedb_client_api::ControlStateReadAccess for StandaloneEnv { } // DNS - fn lookup_identity(&self, domain: &DomainName) -> anyhow::Result> { + fn lookup_identity(&self, domain: &str) -> anyhow::Result> { Ok(self.control_db.spacetime_dns(domain)?) } diff --git a/crates/standalone/src/subcommands/start.rs b/crates/standalone/src/subcommands/start.rs index 60cd6738f2..81c9fd7506 100644 --- a/crates/standalone/src/subcommands/start.rs +++ b/crates/standalone/src/subcommands/start.rs @@ -1,13 +1,15 @@ use std::sync::Arc; -use crate::routes::router; use crate::StandaloneEnv; use anyhow::Context; +use axum::extract::DefaultBodyLimit; use clap::ArgAction::SetTrue; use clap::{Arg, ArgMatches}; use spacetimedb::config::{CertificateAuthority, ConfigFile}; use spacetimedb::db::{Config, Storage}; use spacetimedb::startup::{self, TracingOptions}; +use spacetimedb_client_api::routes::database::DatabaseRoutes; +use spacetimedb_client_api::routes::router; use spacetimedb_paths::cli::{PrivKeyPath, PubKeyPath}; use spacetimedb_paths::server::ServerDataDir; use tokio::net::TcpListener; @@ -132,7 +134,11 @@ pub async fn exec(args: &ArgMatches) -> anyhow::Result<()> { let data_dir = Arc::new(data_dir.clone()); let ctx = StandaloneEnv::init(db_config, &certs, data_dir).await?; - let service = router(ctx); + let mut db_routes = DatabaseRoutes::default(); + db_routes.root_post = db_routes.root_post.layer(DefaultBodyLimit::disable()); + db_routes.db_put = db_routes.db_put.layer(DefaultBodyLimit::disable()); + let extra = axum::Router::new().nest("/health", spacetimedb_client_api::routes::health::router()); + let service = router(&ctx, db_routes, extra).with_state(ctx); let tcp = TcpListener::bind(listen_addr).await?; socket2::SockRef::from(&tcp).set_nodelay(true)?; diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index a61d9f39ba..8d6bbfce28 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -1,5 +1,5 @@ use duct::cmd; -use rand::distributions::{Alphanumeric, DistString}; +use rand::seq::IteratorRandom; use spacetimedb_data_structures::map::HashMap; use spacetimedb_paths::{RootDir, SpacetimePaths}; use std::fs::create_dir_all; @@ -136,7 +136,10 @@ fn status_ok_or_panic(output: std::process::Output, command: &str, test_name: &s } fn random_module_name() -> String { - Alphanumeric.sample_string(&mut rand::thread_rng(), 16) + let mut rng = rand::thread_rng(); + std::iter::repeat_with(|| ('a'..='z').chain('0'..='9').choose(&mut rng).unwrap()) + .take(16) + .collect() } macro_rules! memoized { diff --git a/docker-compose.yml b/docker-compose.yml index ee301a320f..052230d92a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: --jwt-pub-key-path=/etc/spacetimedb/id_ecdsa.pub --jwt-priv-key-path=/etc/spacetimedb/id_ecdsa' healthcheck: - test: curl -f http://localhost/database/ping || exit 1 + test: curl -f http://localhost/ping || exit 1 privileged: true environment: SPACETIMEDB_FLAMEGRAPH_PATH: ../../../../flamegraphs/flamegraph.folded diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 8f4bcd79b0..d91e0f467c 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -77,7 +77,7 @@ def requires_docker(item): return unittest.skip("docker not available")(item) def random_string(k=20): - return ''.join(random.choices(string.ascii_letters, k=k)) + return ''.join(random.choices(string.ascii_lowercase, k=k)) def extract_fields(cmd_output, field_name): """ diff --git a/smoketests/config.toml b/smoketests/config.toml index cd91a9c8a3..02aee7cd04 100644 --- a/smoketests/config.toml +++ b/smoketests/config.toml @@ -1,5 +1,5 @@ default_server = "127.0.0.1:3000" -spacetimedb_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJoZXhfaWRlbnRpdHkiOiJjMjAwODIzOTA3M2M5MDgyNWQxZWY0MWVjNGJlYzg1MmNkNWIzOTdiMzBjZTVhZjUzNmZlZGExOTE3OWM5ZTJjIiwic3ViIjoiMzVmMjQ5ZTYtZjI0NC00ZDE1LWIzYmUtM2Q5NWZjMjA4MTFmIiwiaXNzIjoibG9jYWxob3N0IiwiYXVkIjpbInNwYWNldGltZWRiIl0sImlhdCI6MTczMjYzODI4NywiZXhwIjpudWxsfQ.oVIaYaH7w8ZiuowAflzKo4BrUeGk_1WqlaySMCYqIrkzB96SxVjCQuR0PYM8dOs7WhsiXvYH7dgVxbSbVV4PGg" +spacetimedb_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJoZXhfaWRlbnRpdHkiOiJjMjAwMTk4MDViYzU2ZDczMmUwN2ZjZjlkYWJjYTJkY2I3ZGIwMzMwYjQyOGJjMTI0Y2E0YmMzNWQxNTgzZTkxIiwic3ViIjoiMDMwYmM2NjAtMDdlZC00ZDg0LThkMTYtMDRmMTUzMTgzZjZhIiwiaXNzIjoibG9jYWxob3N0IiwiYXVkIjpbInNwYWNldGltZWRiIl0sImlhdCI6MTczODk1MjY3OCwiZXhwIjpudWxsfQ.TInoGjszYrkVw6vqk_vfj_zgirJcf2-8aNQZHr8e0dJRYK9sCMh2RAjJ_odTbgXRmEIaW416vOZjFGVzuUH6Sg" [[server_configs]] nickname = "localhost" diff --git a/smoketests/tests/domains.py b/smoketests/tests/domains.py index 83e301bcde..f91a8e2801 100644 --- a/smoketests/tests/domains.py +++ b/smoketests/tests/domains.py @@ -1,4 +1,5 @@ from .. import Smoketest, random_string +import unittest class Domains(Smoketest): AUTOPUBLISH = False @@ -19,6 +20,7 @@ class Domains(Smoketest): # Now we're essentially just testing that it *doesn't* throw an exception self.spacetime("logs", rand_name) + @unittest.expectedFailure def test_subdomain_behavior(self): """Test how we treat the / character in published names""" diff --git a/smoketests/tests/zz_docker.py b/smoketests/tests/zz_docker.py index 46854723b0..997cfd1843 100644 --- a/smoketests/tests/zz_docker.py +++ b/smoketests/tests/zz_docker.py @@ -29,7 +29,7 @@ def ping(): tries += 1 try: print(f"Ping Server at {host}") - urlopen(f"http://{host}/database/ping") + urlopen(f"http://{host}/v1/ping") print("Server up") break except Exception: