From 0883292fbaacc583446964722af45778d3da33a0 Mon Sep 17 00:00:00 2001 From: Dhghomon Date: Sat, 3 Feb 2024 22:32:19 +0900 Subject: [PATCH 1/6] Rough unedited content --- docs/guides/tutorials/index.rst | 1 + docs/guides/tutorials/rust_axum.rst | 915 ++++++++++++++++++++++++++++ 2 files changed, 916 insertions(+) create mode 100644 docs/guides/tutorials/rust_axum.rst diff --git a/docs/guides/tutorials/index.rst b/docs/guides/tutorials/index.rst index b291673d35e..8e428c29e77 100644 --- a/docs/guides/tutorials/index.rst +++ b/docs/guides/tutorials/index.rst @@ -14,4 +14,5 @@ Using EdgeDB with... phoenix_github_oauth graphql_apis_with_strawberry chatgpt_bot + rust_axum Bun diff --git a/docs/guides/tutorials/rust_axum.rst b/docs/guides/tutorials/rust_axum.rst new file mode 100644 index 00000000000..dd619a40406 --- /dev/null +++ b/docs/guides/tutorials/rust_axum.rst @@ -0,0 +1,915 @@ +========= +Rust Axum +========= + +The schema is simple but leverages a lot of EdgeDB's guarantees so that +we don't have to think about them on the client side. + +.. code-block:: edgeql + + module default { + + scalar type Latitude extending float64 { + constraint max_value(90.0); + constraint min_value(-90.0); + } + + scalar type Longitude extending float64 { + constraint max_value(180.0); + constraint min_value(-180.0); + } + + scalar type Temperature extending float64 { + constraint max_value(70.0); + constraint min_value(-100.0); + } + + type City { + required name: str { + constraint exclusive; + } + required latitude: Latitude; + required longitude: Longitude; + multi conditions := (select ..latitude ++ .longitude; + + constraint exclusive on (.key); + } + + +Rust code +--------- + +Dependencies: + +.. code-block:: + + anyhow = "1.0.79" + axum = "0.7.4" + edgedb-errors = "0.4.1" + edgedb-protocol = "0.6.0" + edgedb-tokio = "0.5" + reqwest = "0.11.24" + serde = "1.0.196" + serde_json = "1.0.113" + tokio = { version = "1.36.0", features = ["rt", "macros"] } + +Use statements: + +.. code-block:: + + use axum::{ + extract::{Path, State}, + routing::get, + Router, + }; + + use edgedb_errors::ConstraintViolationError; + use edgedb_protocol::value::Value; + use edgedb_tokio::{create_client, Client, Queryable}; + use serde::Deserialize; + use std::time::Duration; + use tokio::{time::sleep, net::TcpListener}; + +The first part of the code is just a few functions that return a String or a +&'static str so that we can review all the queries we will need in one place +and keep the following code clean. The ``select_city`` function also has an +optional filter, and uses a ``mut String`` instead of the ``format!`` macro +so that we don't need to escape the single braces with ``{{`` everywhere. + +.. code-block:: rust + + fn select_city(filter: &str) -> String { + let mut output = "select City { + name, + latitude, + longitude, + conditions: { temperature, time } + } " + .to_string(); + output.push_str(filter); + output + } + + fn insert_city() -> &'static str { + "insert City { + name := $0, + latitude := $1, + longitude := $2, + };" + } + + fn insert_conditions() -> &'static str { + "insert Conditions { + city := (select City filter .name = $0), + temperature := $1, + time := $2 + }" + } + + fn delete_city() -> &'static str { + "delete City filter .name = $0" + } + + fn select_city_names() -> &'static str { + "select City.name order by City.name" + } + +Next are a few structs to work with the output from Open-Meteo, and a function +that uses ``reqwest`` to get the weather information we need and deserialize +it into a Rust type. + +.. code-block:: rust + + #[derive(Queryable)] + struct City { + name: String, + latitude: f64, + longitude: f64, + conditions: Option>, + } + + #[derive(Deserialize, Queryable)] + struct WeatherResult { + current_weather: CurrentWeather, + } + + #[derive(Deserialize, Queryable)] + struct CurrentWeather { + temperature: f64, + time: String, + } + + async fn weather_for(latitude: f64, longitude: f64) -> + Result + { + let url = format!("https://api.open-meteo.com/v1/forecast?\ + latitude={latitude}&longitude={longitude}\ + ¤t_weather=true&timezone=CET"); + let res = reqwest::get(url).await?.text().await?; + let weather_result: WeatherResult = serde_json::from_str(&res)?; + Ok(weather_result.current_weather) + } + +Next up is the app itself! It's called a ``WeatherApp`` and simply holds the +Client to connect to EdgeDB. + +.. code-block:: rust + + struct WeatherApp { + db: Client, + } + +Then inside ``impl WeatherApp`` we have a few methods. + +First there is ``init()``, which just gives the app some initial data. Andorra +is a small enough country that we can insert six cities and have full coverage +of its weather conditions, so we will go with that. Note that the ``Error`` +type for the EdgeDB client has an ``.is()`` method that lets us check what +sort of error was returned, and here we will check for a +``ConstraintViolationError`` to see if a city has already been inserted, and +otherwise print an "Unexpected error" for anything else. + +.. code-block:: rust + + async fn init(&self) { + let city_data = [ + ("Andorra la Vella", 42.3, 1.3), + ("El Serrat", 42.37, 1.33), + ("Encamp", 42.32, 1.35), + ("Les Escaldes", 42.3, 1.32), + ("Sant Julià de Lòria", 42.28, 1.29), + ("Soldeu", 42.34, 1.4), + ]; + + let query = insert_city(); + for (name, lat, long) in city_data { + match self.db.execute(query, &(name, lat, long)).await { + Ok(_) => println!("City {name} inserted!"), + Err(e) => { + if e.is::() { + println!("City {name} already in db"); + } else { + println!("Unexpected error: {e:?}"); + } + } + } + } + } + +The ``.get_cities()`` method simply gets all the cities in the database +without filtering. The ``.update_conditions()`` method then uses this +to cycle through the cities and get their weather conditions. The +``Conditions`` type in our database has a +``constraint exclusive on ((.time, .city));`` so most of the time the +result from Open-Meteo will violate this and a new object will not be +inserted, and so inside ``update_conditions`` we won't do anything if +this is the case. In practice we know that new conditions will only be +added every 15 minutes, but there is no guarantee what Open-Meteo's future +behavior might be, or if our weather app will start using another service +or multiple services to get weather info, so the easiest thing to do is just +keep looping. + +.. code-block:: rust + + async fn get_cities(&self) -> Result, anyhow::Error> { + Ok(self.db.query::(&select_city(""), &()).await?) + } + + async fn update_conditions(&self) -> Result<(), anyhow::Error> { + for City { + name, + latitude, + longitude, + .. + } in self.get_cities().await? + { + let CurrentWeather { temperature, time } = weather_for(latitude, longitude).await?; + + match self + .db + .execute(insert_conditions(), &(&name, temperature, time)) + .await + { + Ok(()) => println!("Inserted new conditions for {}", name), + Err(e) => { + if !e.is::() { + println!("Unexpected error: {e}"); + } + } + } + } + Ok(()) + } + +Finally, a ``.run()`` method will get our ``WeatherApp`` to run forever, +sleeping for 60 seconds each time. (Weather doesn't change that often...) + +.. code-block:: rust + + async fn run(&self) { + loop { + println!("Looping..."); + if let Err(e) = self.update_conditions().await { + println!("Loop isn't working: {e}") + } + sleep(Duration::from_secs(60)).await; + } + } + } + +So that code will be enough to have an app that loops forever, looking for +new weather information. But we'd also like users to be able to add and +remove cities, and Axum will allow us to add some endpoints to make this +happen. To start, we'll put a ``menu()`` function together that simply +lists the endpoints so that the user knows what options are available. +Note that the function is an ``async fn`` because Axum requires all routes +to be handled by an async function (or closure). + +.. code-block:: rust + + async fn menu() -> &'static str { + "Routes: + /conditions/ + /add_city/// + /remove_city/ + /city_names" + } + +So this will allow users to see the conditions for a city, to add a city +along with its location, remove a city, and also display a list of all city +names in the database. + +Before we get to the functions for each endpoint, we should take a look at +``main()`` to get an idea of what everything will look like. We will first +create a ``Client`` to the database, and add it as a parameter inside the +``WeatherApp``. Cloning an EdgeDB Client is cheap and easy to do, so we will +do this and then add the ``Client`` to Axum's ``.with_state()`` method, which +will make it available inside the Axum endpoint functions whenever we need it. +Meanwhile, the ``WeatherApp`` will simply ``.run()`` forever inside its own +tokio task. + +All together, the code for ``main()`` looks like this: + +.. code-block:: rust + + #[tokio::main] + async fn main() -> Result<(), anyhow::Error> { + let client = create_client().await?; + + let weather_app = WeatherApp { db: client.clone() }; + + weather_app.init().await; + + tokio::task::spawn(async move { + weather_app.run().await; + }); + + let app = Router::new() + .route("/", get(menu)) + .route("/conditions/:name", get(get_conditions)) + .route("/add_city/:name/:latitude/:longitude", get(add_city)) + .route("/remove_city/:name", get(remove_city)) + .route("/city_names", get(city_names)) + .with_state(client) + .into_make_service(); + + let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app).await.unwrap(); + Ok(()) + } + +Finally, we just need to write the Axum functions. + +Removing a City is pretty easy, just use this query returned by the +``delete_city()`` function and do a query with it. + +.. code-block:: + + "delete City filter .name = $0" + +We don't need to deserialize the result, and instead can just return a +``Vec`` and check to see if it's empty or not. If it's empty, +then no city matched the name we specified. + +Also note the destructuring inside function signatures here, which is pretty +convenient! Axum makes use of this pattern in its examples quite a bit. + +.. code-block:: rust + + async fn remove_city(Path(name): Path, State(client): State) -> String { + match client + .query::(delete_city(), &(&name,)) + .await + { + Ok(v) if v.is_empty() => format!("No city {name} found to remove!"), + Ok(_) => format!("City {name} removed!"), + Err(e) => e.to_string(), + } + } + +Getting a list of city names is just as easy. The query is just a few words long: + +.. code-block:: + + "select City.name order by City.name" + +And so is the method to do the query. It will just return a set of strings, +so we don't even need to deserialize it into a Rust type: + +.. code-block:: rust + + async fn city_names(State(client): State) -> String { + match client + .query::(select_city_names(), &()) + .await + { + Ok(cities) => format!("{cities:#?}"), + Err(e) => e.to_string(), + } + } + +The next function is ``get_conditions()``, which users will make the most +use of. The query is a simple ``select``: + +.. code-block:: + + "select City { + name, + latitude, + longitude, + conditions: { temperature, time } + } " + +After which we will filter on the name of the ``City``. The method used here is +``.query_required_single()``, because we know that only a single ``City`` can +be returned thanks to the ``exclusive`` constraint on its ``name`` property. +Don't forget that our ``City`` objects already order their weather conditions +by time, so we don't need to do any ordering ourselves: + +.. code-block:: + + multi conditions := (select ., State(client): State) -> String { + let query = select_city("filter .name = $0"); + match client + .query_required_single::(&query, &(&city_name,)) + .await + { + Ok(city) => { + let mut conditions = format!("Conditions for {city_name}:\n\n"); + for condition in city.conditions.unwrap_or_default() { + let (date, hour) = condition.time.split_once("T").unwrap_or_default(); + conditions.push_str(&format!("{date} {hour}\t")); + conditions.push_str(&format!("{}\n", condition.temperature)); + } + conditions + } + Err(e) => format!("Couldn't find {city_name}: {e}"), + } + } + +Adding a ``City`` is a tiny bit more complicated, because we don't know +exactly how Open-Meteo's internals work. That means that there is always +a chance that a request might not work for some reason, and in that case +we don't want to insert a ``City`` into our database because then the +``WeatherApp`` will just keep giving requisting data from Open-Meteo that +it refuses to provide. + +In fact, you can take a look at this by trying a query for Open-Meteo for +a location at latitude 80.0 or longitude 180.0. They won't work, because +Open-Meteo allows queries *up to or less than* these values, but in our +database we allow these values to be *up to* 80.0 and 180.0. This example +code pretends that we didn't notice that. Plus, there is no guarantee that +Open-Meteo will be the only service that our weather app uses. + +So that means that the ``add_city`` function will first make sure that +Open-Meteo returns a good result, and only then inserts a City. Finally, +it will get the most recent conditions for the new city. These two steps +could be done in a single query in EdgeDB, but doing it one simple step at +a time feels most readable here and allows us to see at which point an error +happens if that is the case. + +.. code-block:: rust + + async fn add_city( + State(client): State, + Path((name, lat, long)): Path<(String, f64, f64)>, + ) -> String { + // First make sure that Open-Meteo is okay with it + let (temperature, time) = match weather_for(lat, long).await { + Ok(c) => (c.temperature, c.time), + Err(e) => { + return format!("Couldn't get weather info: {e}"); + } + }; + + // Then insert the City + let _ = client + .execute(insert_city(), &(&name, lat, long)) + .await + .or_else(|e| { + return Err(e.to_string()); + }); + + // And finally the Conditions + let _ = client + .execute(insert_conditions(), &(&name, temperature, time)) + .await + .or_else(|e| { + return Err(format!( + "Inserted City {name} but couldn't insert conditions: {e}" + )); + }); + format!("Inserted city {name}!") + } + +And with that, we have our app! Running the app inside the console should +produce the following output, with extra lines for any cities you add +yourself. + +.. code-block:: + + Inserted new conditions for Andorra la Vella + Inserted new conditions for Encamp + Inserted new conditions for Les Escaldes + Inserted new conditions for Sant Julià de Lòria + Inserted new conditions for Soldeu + Inserted new conditions for El Serrat + Looping... + Looping... + Looping... + Looping... + Looping... + Looping... + Looping... + Looping... + Looping... + +And inside your browser you should be able to see any city you like with +an address like the following: ``http://localhost:3000/conditions/El Serrat`` +The output will look like this: + +.. code-block:: + + Conditions for El Serrat: + + 2024-02-05 01:30 4.5 + 2024-02-05 02:15 4.6 + 2024-02-05 02:30 4.5 + 2024-02-05 02:45 4.7 + 2024-02-05 03:00 4.7 + 2024-02-05 03:15 4.6 + 2024-02-05 03:30 4.7 + ... and so on... + +So that's how to get started with EdgeDB and Axum! You can now use this code +as a template to modify to get your own app started. Rust's other main web +servers are implemented with Actix-web and Rocket, and modifying the code +to fit them is not all that hard. + +Here is all of the Rust code: + +.. code-block:: rust + + use axum::{ + extract::{Path, State}, + routing::get, + Router, + }; + + use edgedb_errors::ConstraintViolationError; + use edgedb_protocol::value::Value; + use edgedb_tokio::{create_client, Client, Queryable}; + use serde::Deserialize; + use std::time::Duration; + use tokio::{time::sleep, net::TcpListener}; + + fn select_city(filter: &str) -> String { + let mut output = "select City { + name, + latitude, + longitude, + conditions: { temperature, time } + } " + .to_string(); + output.push_str(filter); + output + } + + fn insert_city() -> &'static str { + "insert City { + name := $0, + latitude := $1, + longitude := $2, + };" + } + + fn insert_conditions() -> &'static str { + "insert Conditions { + city := (select City filter .name = $0), + temperature := $1, + time := $2 + }" + } + + fn delete_city() -> &'static str { + "delete City filter .name = $0" + } + + fn select_city_names() -> &'static str { + "select City.name order by City.name" + } + + #[derive(Queryable)] + struct City { + name: String, + latitude: f64, + longitude: f64, + conditions: Option>, + } + + #[derive(Deserialize, Queryable)] + struct WeatherResult { + current_weather: CurrentWeather, + } + + #[derive(Deserialize, Queryable)] + struct CurrentWeather { + temperature: f64, + time: String, + } + + async fn weather_for(latitude: f64, longitude: f64) -> Result { + let url = format!("https://api.open-meteo.com/v1/forecast?\ + latitude={latitude}&longitude={longitude}\ + ¤t_weather=true&timezone=CET"); + let res = reqwest::get(url).await?.text().await?; + let weather_result: WeatherResult = serde_json::from_str(&res)?; + Ok(weather_result.current_weather) + } + + struct WeatherApp { + db: Client, + } + + impl WeatherApp { + async fn init(&self) { + let city_data = [ + ("Andorra la Vella", 42.3, 1.3), + ("El Serrat", 42.37, 1.33), + ("Encamp", 42.32, 1.35), + ("Les Escaldes", 42.3, 1.32), + ("Sant Julià de Lòria", 42.28, 1.29), + ("Soldeu", 42.34, 1.4), + ]; + + let query = insert_city(); + for (name, lat, long) in city_data { + match self.db.execute(query, &(name, lat, long)).await { + Ok(_) => println!("City {name} inserted!"), + Err(e) => { + if e.is::() { + println!("City {name} already in db"); + } else { + println!("Unexpected error: {e:?}"); + } + } + } + } + } + + async fn get_cities(&self) -> Result, anyhow::Error> { + Ok(self.db.query::(&select_city(""), &()).await?) + } + + async fn update_conditions(&self) -> Result<(), anyhow::Error> { + for City { + name, + latitude, + longitude, + .. + } in self.get_cities().await? + { + let CurrentWeather { temperature, time } = weather_for(latitude, longitude).await?; + + match self + .db + .execute(insert_conditions(), &(&name, temperature, time)) + .await + { + Ok(()) => println!("Inserted new conditions for {}", name), + Err(e) => { + if !e.is::() { + println!("Unexpected error: {e}"); + } + } + } + } + Ok(()) + } + + async fn run(&self) { + sleep(Duration::from_millis(100)).await; + loop { + println!("Looping..."); + if let Err(e) = self.update_conditions().await { + println!("Loop isn't working: {e}") + } + sleep(Duration::from_secs(60)).await; + } + } + } + + // Axum functions + + async fn menu() -> &'static str { + "Routes: + /conditions/ + /add_city/// + /remove_city/ + /city_names" + } + + async fn get_conditions(Path(city_name): Path, State(client): State) -> String { + let query = select_city("filter .name = $0"); + match client + .query_required_single::(&query, &(&city_name,)) + .await + { + Ok(city) => { + let mut conditions = format!("Conditions for {city_name}:\n\n"); + for condition in city.conditions.unwrap_or_default() { + let (date, hour) = condition.time.split_once("T").unwrap_or_default(); + conditions.push_str(&format!("{date} {hour}\t")); + conditions.push_str(&format!("{}\n", condition.temperature)); + } + conditions + } + Err(e) => format!("Couldn't find {city_name}: {e}"), + } + } + + async fn add_city( + State(client): State, + Path((name, lat, long)): Path<(String, f64, f64)>, + ) -> String { + // First make sure that Open-Meteo is okay with it + let (temperature, time) = match weather_for(lat, long).await { + Ok(c) => (c.temperature, c.time), + Err(e) => { + return format!("Couldn't get weather info: {e}"); + } + }; + + // Then insert the City + let _ = client + .execute(insert_city(), &(&name, lat, long)) + .await + .or_else(|e| { + return Err(e.to_string()); + }); + + // And finally the Conditions + let _ = client + .execute(insert_conditions(), &(&name, temperature, time)) + .await + .or_else(|e| { + return Err(format!( + "Inserted City {name} but couldn't insert conditions: {e}" + )); + }); + format!("Inserted city {name}!") + } + + async fn remove_city(Path(name): Path, State(client): State) -> String { + match client + .query::(delete_city(), &(&name,)) + .await + { + Ok(v) if v.is_empty() => format!("No city {name} found to remove!"), + Ok(_) => format!("City {name} removed!"), + Err(e) => e.to_string(), + } + } + + async fn city_names(State(client): State) -> String { + match client + .query::(select_city_names(), &()) + .await + { + Ok(cities) => format!("{cities:#?}"), + Err(e) => e.to_string(), + } + } + + #[tokio::main] + async fn main() -> Result<(), anyhow::Error> { + let client = create_client().await?; + + let weather_app = WeatherApp { db: client.clone() }; + + weather_app.init().await; + + tokio::task::spawn(async move { + weather_app.run().await; + }); + + let app = Router::new() + .route("/", get(menu)) + .route("/conditions/:name", get(get_conditions)) + .route("/add_city/:name/:latitude/:longitude", get(add_city)) + .route("/remove_city/:name", get(remove_city)) + .route("/city_names", get(city_names)) + .with_state(client) + .into_make_service(); + + let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app).await.unwrap(); + Ok(()) + } From 2a5a605b90417dff899f2bec8bb559b3f202c94c Mon Sep 17 00:00:00 2001 From: Dhghomon Date: Sun, 4 Feb 2024 00:29:21 +0900 Subject: [PATCH 2/6] Start proofreading --- docs/guides/tutorials/rust_axum.rst | 607 +++++++++++++++------------- 1 file changed, 323 insertions(+), 284 deletions(-) diff --git a/docs/guides/tutorials/rust_axum.rst b/docs/guides/tutorials/rust_axum.rst index dd619a40406..90dcfb19caa 100644 --- a/docs/guides/tutorials/rust_axum.rst +++ b/docs/guides/tutorials/rust_axum.rst @@ -2,6 +2,27 @@ Rust Axum ========= +This guide will show you step by step how to create a small working app +in Rust that uses Axum as its web server and EdgeDB to hold weather data. +The app itself simply calls into a service called Open-Meteo once a minute +to look for updated weather information on the cities in the database, and +goes back to sleep once it is done. Open-Meteo is being used here because +their service doesn't require any sort of registration. Give it a try in +your browser +`here! `_. + +Getting started +--------------- + +To get started, first type ``cargo new weather_app``, or whatever name +you would like to give the project. Go into the directory that was created +and type ``edgedb project init`` to start an EdgeDB instance. Inside, you +will see your schema inside the ``default.esdl`` in the ``/dbschema`` +directory. + +Schema +------ + The schema is simple but leverages a lot of EdgeDB's guarantees so that we don't have to think about them on the client side. @@ -43,19 +64,25 @@ we don't have to think about them on the client side. } } -Three scalar types have been extended to give us some type safety when it -comes to Latitude, Longitude, and Temperature. Latitude can't exceed -±90.0 on Earth, and longitude can't exceed ±180.0. Open-Meteo does its own -checks for latitude and longitude when querying the conditions for a location, -but we might decide to switch to a different weather service one day and -having constraints up front makes it easy for users to see which values are -allowed and which are not. +Let's go over some of the advantages EdgeDB gives us even in a schema as +simple as this one. + +First are the three scalar types have been extended to give us some type +safety when it comes to latitude, longitude, and temperature. Latitude can't +exceed 90 degrees on Earth, and longitude can't exceed 180. Open-Meteo does +its own checks for latitude and longitude when querying the conditions for a +location, but we might decide to switch to a different weather service one day +and having constraints up front makes it easy for users to see which values +are allowed and which are not. Plus, sometimes another server's data will just go haywire for some reason -or another, such as the time a weather map predicted a high of thousands of -degrees for various cities in Arizona. https://www.youtube.com/watch?v=iXuc7SAyk2s With the -constraints in place, we are at least guaranteed to not add temperature data -that reaches that point! +or another, such as the time a weather map showed a high of +`thousands of degrees `_ for +various cities in Arizona. With the constraints in place, we are at least +guaranteed to not add temperature data that reaches that point! We'll go +with a maximum of 70.0 and low of 100.0 degrees. (The highest and lowest +temperatures over recorded on Earth are 56.7 °C and -89.2°C, so our +constraints provide a lot of room.) .. code-block:: edgeql @@ -75,12 +102,11 @@ that reaches that point! } Open-Meteo returns a good deal of information when you query it for current -weather: +weather. If you clicked on the link above, you will have seen an output that +looks like the following: .. code-block:: - # Query: https://api.open-meteo.com/v1/forecast?latitude=50&longitude=50¤t_weather=true&timezone=CET - { "latitude": 49.9375, "longitude": 50, @@ -109,9 +135,14 @@ weather: } } -But we only want the ``time`` and ``temperature`` located inside -``current_weather``. We can then use this info to insert a type called -``Conditions`` that will look like this: + +But we only need the ``time`` and ``temperature`` located inside +``current_weather``. (Small challenge: feel free to grow the schema with +other scalar types to incorporate all the other information returned +from Open-Meteo!) + +We can then use this info to insert a type called ``Conditions`` that +will look like this: .. code-block:: edgeql @@ -152,32 +183,44 @@ The ``City`` type is pretty simple: The line with ``multi conditions := (select ..latitude ++ .longitude; - + constraint exclusive on (.key); - } + + key := .name ++ .latitude ++ .longitude; + + constraint exclusive on (.key); + } + +You could give this or another method a try if you are feeling ambitious. +And with that out of the way, let's move on to the Rust code. Rust code --------- -Dependencies: +Here are the dependencies you will need to add to ``cargo.toml`` (with +the exception of ``anyhow`` which isn't strictly needed but is always +nice to use). .. code-block:: @@ -191,7 +234,7 @@ Dependencies: serde_json = "1.0.113" tokio = { version = "1.36.0", features = ["rt", "macros"] } -Use statements: +And then a few use statements at the top: .. code-block:: @@ -208,11 +251,15 @@ Use statements: use std::time::Duration; use tokio::{time::sleep, net::TcpListener}; -The first part of the code is just a few functions that return a String or a -&'static str so that we can review all the queries we will need in one place -and keep the following code clean. The ``select_city`` function also has an +And now to the real code. + +The first part of the code is just a few functions that return a ``String`` or +a ``&'static str``. They aren't strictly necessary, but are nice to have on +so that we can review all the queries we will need in one place and keep the +following code clean. Note that the ``select_city()`` function also has an optional filter, and uses a ``mut String`` instead of the ``format!`` macro -so that we don't need to escape the single braces with ``{{`` everywhere. +because inside ``format!`` you need to use ``{{`` double braces in place of +single braces, which quickly makes things ugly. .. code-block:: rust @@ -252,9 +299,9 @@ so that we don't need to escape the single braces with ``{{`` everywhere. "select City.name order by City.name" } -Next are a few structs to work with the output from Open-Meteo, and a function -that uses ``reqwest`` to get the weather information we need and deserialize -it into a Rust type. +Next are a few structs to work with the output from Open-Meteo, and a +function that uses ``reqwest`` to get the weather information we need and +deserialize it into a Rust type. .. code-block:: rust @@ -299,13 +346,14 @@ Client to connect to EdgeDB. Then inside ``impl WeatherApp`` we have a few methods. -First there is ``init()``, which just gives the app some initial data. Andorra -is a small enough country that we can insert six cities and have full coverage -of its weather conditions, so we will go with that. Note that the ``Error`` -type for the EdgeDB client has an ``.is()`` method that lets us check what -sort of error was returned, and here we will check for a -``ConstraintViolationError`` to see if a city has already been inserted, and -otherwise print an "Unexpected error" for anything else. +First there is ``init()``, which just gives the app some initial data. We'll +choose the small country of Andorra located in between Spain and France and +where the Catalan language is spoken. With a country of that size we can +insert just six cities and have full coverage of its nationwide weather +conditions. Note that the ``Error`` type for the EdgeDB client has an +``.is()`` method that lets us check what sort of error was returned. We will +use it to check for a ``ConstraintViolationError`` to see if a city has +already been inserted, and otherwise print an "Unexpected error" for anything else. .. code-block:: rust @@ -558,7 +606,7 @@ Adding a ``City`` is a tiny bit more complicated, because we don't know exactly how Open-Meteo's internals work. That means that there is always a chance that a request might not work for some reason, and in that case we don't want to insert a ``City`` into our database because then the -``WeatherApp`` will just keep giving requisting data from Open-Meteo that +``WeatherApp`` will just keep giving requesting data from Open-Meteo that it refuses to provide. In fact, you can take a look at this by trying a query for Open-Meteo for @@ -568,7 +616,7 @@ database we allow these values to be *up to* 80.0 and 180.0. This example code pretends that we didn't notice that. Plus, there is no guarantee that Open-Meteo will be the only service that our weather app uses. -So that means that the ``add_city`` function will first make sure that +So that means that the ``add_city()`` function will first make sure that Open-Meteo returns a good result, and only then inserts a City. Finally, it will get the most recent conditions for the new city. These two steps could be done in a single query in EdgeDB, but doing it one simple step at @@ -590,23 +638,18 @@ happens if that is the case. }; // Then insert the City - let _ = client - .execute(insert_city(), &(&name, lat, long)) - .await - .or_else(|e| { - return Err(e.to_string()); - }); + if let Err(e) = client.execute(insert_city(), &(&name, lat, long)).await { + return e.to_string(); + } // And finally the Conditions - let _ = client + if let Err(e) = client .execute(insert_conditions(), &(&name, temperature, time)) .await - .or_else(|e| { - return Err(format!( - "Inserted City {name} but couldn't insert conditions: {e}" - )); - }); - format!("Inserted city {name}!") + { + return format!("Inserted City {name} but couldn't insert conditions: {e}"); + } + format!("Inserted city {name}!") } And with that, we have our app! Running the app inside the console should @@ -655,261 +698,257 @@ to fit them is not all that hard. Here is all of the Rust code: +.. lint-off + .. code-block:: rust - use axum::{ - extract::{Path, State}, - routing::get, - Router, - }; + use axum::{ + extract::{Path, State}, + routing::get, + Router, + }; - use edgedb_errors::ConstraintViolationError; - use edgedb_protocol::value::Value; - use edgedb_tokio::{create_client, Client, Queryable}; - use serde::Deserialize; - use std::time::Duration; - use tokio::{time::sleep, net::TcpListener}; + use edgedb_errors::ConstraintViolationError; + use edgedb_protocol::value::Value; + use edgedb_tokio::{create_client, Client, Queryable}; + use serde::Deserialize; + use std::time::Duration; + use tokio::{net::TcpListener, time::sleep}; - fn select_city(filter: &str) -> String { - let mut output = "select City { - name, - latitude, - longitude, - conditions: { temperature, time } - } " - .to_string(); - output.push_str(filter); - output - } + fn select_city(filter: &str) -> String { + let mut output = "select City { + name, + latitude, + longitude, + conditions: { temperature, time } + } " + .to_string(); + output.push_str(filter); + output + } - fn insert_city() -> &'static str { - "insert City { - name := $0, - latitude := $1, - longitude := $2, - };" - } + fn insert_city() -> &'static str { + "insert City { + name := $0, + latitude := $1, + longitude := $2, + };" + } - fn insert_conditions() -> &'static str { - "insert Conditions { - city := (select City filter .name = $0), - temperature := $1, - time := $2 - }" - } + fn insert_conditions() -> &'static str { + "insert Conditions { + city := (select City filter .name = $0), + temperature := $1, + time := $2 + }" + } - fn delete_city() -> &'static str { - "delete City filter .name = $0" - } + fn delete_city() -> &'static str { + "delete City filter .name = $0" + } - fn select_city_names() -> &'static str { - "select City.name order by City.name" - } + fn select_city_names() -> &'static str { + "select City.name order by City.name" + } - #[derive(Queryable)] - struct City { - name: String, - latitude: f64, - longitude: f64, - conditions: Option>, - } + #[derive(Queryable)] + struct City { + name: String, + latitude: f64, + longitude: f64, + conditions: Option>, + } - #[derive(Deserialize, Queryable)] - struct WeatherResult { - current_weather: CurrentWeather, - } + #[derive(Deserialize, Queryable)] + struct WeatherResult { + current_weather: CurrentWeather, + } - #[derive(Deserialize, Queryable)] - struct CurrentWeather { - temperature: f64, - time: String, - } + #[derive(Deserialize, Queryable)] + struct CurrentWeather { + temperature: f64, + time: String, + } - async fn weather_for(latitude: f64, longitude: f64) -> Result { - let url = format!("https://api.open-meteo.com/v1/forecast?\ - latitude={latitude}&longitude={longitude}\ - ¤t_weather=true&timezone=CET"); - let res = reqwest::get(url).await?.text().await?; - let weather_result: WeatherResult = serde_json::from_str(&res)?; - Ok(weather_result.current_weather) - } + async fn weather_for(latitude: f64, longitude: f64) -> Result { + let url = format!( + "https://api.open-meteo.com/v1/forecast?\ + latitude={latitude}&longitude={longitude}\ + ¤t_weather=true&timezone=CET" + ); + let res = reqwest::get(url).await?.text().await?; + let weather_result: WeatherResult = serde_json::from_str(&res)?; + Ok(weather_result.current_weather) + } - struct WeatherApp { - db: Client, - } + struct WeatherApp { + db: Client, + } - impl WeatherApp { - async fn init(&self) { - let city_data = [ - ("Andorra la Vella", 42.3, 1.3), - ("El Serrat", 42.37, 1.33), - ("Encamp", 42.32, 1.35), - ("Les Escaldes", 42.3, 1.32), - ("Sant Julià de Lòria", 42.28, 1.29), - ("Soldeu", 42.34, 1.4), - ]; - - let query = insert_city(); - for (name, lat, long) in city_data { - match self.db.execute(query, &(name, lat, long)).await { - Ok(_) => println!("City {name} inserted!"), - Err(e) => { - if e.is::() { - println!("City {name} already in db"); - } else { - println!("Unexpected error: {e:?}"); - } - } - } - } - } + impl WeatherApp { + async fn init(&self) { + let city_data = [ + ("Andorra la Vella", 42.3, 1.3), + ("El Serrat", 42.37, 1.33), + ("Encamp", 42.32, 1.35), + ("Les Escaldes", 42.3, 1.32), + ("Sant Julià de Lòria", 42.28, 1.29), + ("Soldeu", 42.34, 1.4), + ]; + + let query = insert_city(); + for (name, lat, long) in city_data { + match self.db.execute(query, &(name, lat, long)).await { + Ok(_) => println!("City {name} inserted!"), + Err(e) => { + if e.is::() { + println!("City {name} already in db"); + } else { + println!("Unexpected error: {e:?}"); + } + } + } + } + } - async fn get_cities(&self) -> Result, anyhow::Error> { - Ok(self.db.query::(&select_city(""), &()).await?) - } + async fn get_cities(&self) -> Result, anyhow::Error> { + Ok(self.db.query::(&select_city(""), &()).await?) + } - async fn update_conditions(&self) -> Result<(), anyhow::Error> { - for City { - name, - latitude, - longitude, - .. - } in self.get_cities().await? - { - let CurrentWeather { temperature, time } = weather_for(latitude, longitude).await?; - - match self - .db - .execute(insert_conditions(), &(&name, temperature, time)) - .await - { - Ok(()) => println!("Inserted new conditions for {}", name), - Err(e) => { - if !e.is::() { - println!("Unexpected error: {e}"); - } - } - } - } - Ok(()) - } + async fn update_conditions(&self) -> Result<(), anyhow::Error> { + for City { + name, + latitude, + longitude, + .. + } in self.get_cities().await? + { + let CurrentWeather { temperature, time } = weather_for(latitude, longitude).await?; + + match self + .db + .execute(insert_conditions(), &(&name, temperature, time)) + .await + { + Ok(()) => println!("Inserted new conditions for {}", name), + Err(e) => { + if !e.is::() { + println!("Unexpected error: {e}"); + } + } + } + } + Ok(()) + } - async fn run(&self) { - sleep(Duration::from_millis(100)).await; - loop { - println!("Looping..."); - if let Err(e) = self.update_conditions().await { - println!("Loop isn't working: {e}") - } - sleep(Duration::from_secs(60)).await; - } - } - } + async fn run(&self) { + sleep(Duration::from_millis(100)).await; + loop { + println!("Looping..."); + if let Err(e) = self.update_conditions().await { + println!("Loop isn't working: {e}") + } + sleep(Duration::from_secs(60)).await; + } + } + } - // Axum functions + // Axum functions - async fn menu() -> &'static str { - "Routes: - /conditions/ - /add_city/// - /remove_city/ - /city_names" - } + async fn menu() -> &'static str { + "Routes: + /conditions/ + /add_city/// + /remove_city/ + /city_names" + } - async fn get_conditions(Path(city_name): Path, State(client): State) -> String { - let query = select_city("filter .name = $0"); - match client - .query_required_single::(&query, &(&city_name,)) - .await - { - Ok(city) => { - let mut conditions = format!("Conditions for {city_name}:\n\n"); - for condition in city.conditions.unwrap_or_default() { - let (date, hour) = condition.time.split_once("T").unwrap_or_default(); - conditions.push_str(&format!("{date} {hour}\t")); - conditions.push_str(&format!("{}\n", condition.temperature)); - } - conditions - } - Err(e) => format!("Couldn't find {city_name}: {e}"), - } - } + async fn get_conditions(Path(city_name): Path, State(client): State) -> String { + let query = select_city("filter .name = $0"); + match client + .query_required_single::(&query, &(&city_name,)) + .await + { + Ok(city) => { + let mut conditions = format!("Conditions for {city_name}:\n\n"); + for condition in city.conditions.unwrap_or_default() { + let (date, hour) = condition.time.split_once("T").unwrap_or_default(); + conditions.push_str(&format!("{date} {hour}\t")); + conditions.push_str(&format!("{}\n", condition.temperature)); + } + conditions + } + Err(e) => format!("Couldn't find {city_name}: {e}"), + } + } - async fn add_city( - State(client): State, - Path((name, lat, long)): Path<(String, f64, f64)>, - ) -> String { - // First make sure that Open-Meteo is okay with it - let (temperature, time) = match weather_for(lat, long).await { - Ok(c) => (c.temperature, c.time), - Err(e) => { - return format!("Couldn't get weather info: {e}"); - } - }; + async fn add_city( + State(client): State, + Path((name, lat, long)): Path<(String, f64, f64)>, + ) -> String { + // First make sure that Open-Meteo is okay with it + let (temperature, time) = match weather_for(lat, long).await { + Ok(c) => (c.temperature, c.time), + Err(e) => { + return format!("Couldn't get weather info: {e}"); + } + }; - // Then insert the City - let _ = client - .execute(insert_city(), &(&name, lat, long)) - .await - .or_else(|e| { - return Err(e.to_string()); - }); + // Then insert the City + if let Err(e) = client.execute(insert_city(), &(&name, lat, long)).await { + return e.to_string(); + } - // And finally the Conditions - let _ = client - .execute(insert_conditions(), &(&name, temperature, time)) - .await - .or_else(|e| { - return Err(format!( - "Inserted City {name} but couldn't insert conditions: {e}" - )); - }); - format!("Inserted city {name}!") - } + // And finally the Conditions + if let Err(e) = client + .execute(insert_conditions(), &(&name, temperature, time)) + .await + { + return format!("Inserted City {name} but couldn't insert conditions: {e}"); + } - async fn remove_city(Path(name): Path, State(client): State) -> String { - match client - .query::(delete_city(), &(&name,)) - .await - { - Ok(v) if v.is_empty() => format!("No city {name} found to remove!"), - Ok(_) => format!("City {name} removed!"), - Err(e) => e.to_string(), - } - } + format!("Inserted city {name}!") + } - async fn city_names(State(client): State) -> String { - match client - .query::(select_city_names(), &()) - .await - { - Ok(cities) => format!("{cities:#?}"), - Err(e) => e.to_string(), - } - } + async fn remove_city(Path(name): Path, State(client): State) -> String { + match client.query::(delete_city(), &(&name,)).await { + Ok(v) if v.is_empty() => format!("No city {name} found to remove!"), + Ok(_) => format!("City {name} removed!"), + Err(e) => e.to_string(), + } + } - #[tokio::main] - async fn main() -> Result<(), anyhow::Error> { - let client = create_client().await?; + async fn city_names(State(client): State) -> String { + match client.query::(select_city_names(), &()).await { + Ok(cities) => format!("{cities:#?}"), + Err(e) => e.to_string(), + } + } + + #[tokio::main] + async fn main() -> Result<(), anyhow::Error> { + let client = create_client().await?; - let weather_app = WeatherApp { db: client.clone() }; + let weather_app = WeatherApp { db: client.clone() }; - weather_app.init().await; + weather_app.init().await; - tokio::task::spawn(async move { - weather_app.run().await; - }); + tokio::task::spawn(async move { + weather_app.run().await; + }); - let app = Router::new() - .route("/", get(menu)) - .route("/conditions/:name", get(get_conditions)) - .route("/add_city/:name/:latitude/:longitude", get(add_city)) - .route("/remove_city/:name", get(remove_city)) - .route("/city_names", get(city_names)) - .with_state(client) - .into_make_service(); + let app = Router::new() + .route("/", get(menu)) + .route("/conditions/:name", get(get_conditions)) + .route("/add_city/:name/:latitude/:longitude", get(add_city)) + .route("/remove_city/:name", get(remove_city)) + .route("/city_names", get(city_names)) + .with_state(client) + .into_make_service(); + + let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app).await.unwrap(); + Ok(()) + } - let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app).await.unwrap(); - Ok(()) - } +.. lint-on \ No newline at end of file From de6325e5e3409388bad916e616cc4b851df117ce Mon Sep 17 00:00:00 2001 From: Dhghomon Date: Sun, 4 Feb 2024 01:45:40 +0900 Subject: [PATCH 3/6] Line length --- docs/guides/tutorials/rust_axum.rst | 56 +++++++++++++++++------------ 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/guides/tutorials/rust_axum.rst b/docs/guides/tutorials/rust_axum.rst index 90dcfb19caa..9f73c56aab4 100644 --- a/docs/guides/tutorials/rust_axum.rst +++ b/docs/guides/tutorials/rust_axum.rst @@ -7,9 +7,8 @@ in Rust that uses Axum as its web server and EdgeDB to hold weather data. The app itself simply calls into a service called Open-Meteo once a minute to look for updated weather information on the cities in the database, and goes back to sleep once it is done. Open-Meteo is being used here because -their service doesn't require any sort of registration. Give it a try in -your browser -`here! `_. +their service doesn't require any sort of registration. Give it a try +`in your browser`_! Getting started --------------- @@ -353,7 +352,8 @@ insert just six cities and have full coverage of its nationwide weather conditions. Note that the ``Error`` type for the EdgeDB client has an ``.is()`` method that lets us check what sort of error was returned. We will use it to check for a ``ConstraintViolationError`` to see if a city has -already been inserted, and otherwise print an "Unexpected error" for anything else. +already been inserted, and otherwise print an "Unexpected error" for anything +else. .. code-block:: rust @@ -409,7 +409,8 @@ keep looping. .. } in self.get_cities().await? { - let CurrentWeather { temperature, time } = weather_for(latitude, longitude).await?; + let CurrentWeather { temperature, time } = + weather_for(latitude, longitude).await?; match self .db @@ -522,18 +523,21 @@ convenient! Axum makes use of this pattern in its examples quite a bit. .. code-block:: rust - async fn remove_city(Path(name): Path, State(client): State) -> String { - match client - .query::(delete_city(), &(&name,)) - .await - { - Ok(v) if v.is_empty() => format!("No city {name} found to remove!"), - Ok(_) => format!("City {name} removed!"), - Err(e) => e.to_string(), - } + async fn remove_city(Path(name): Path, State(client): State) + -> String + { + match client + .query::(delete_city(), &(&name,)) + .await + { + Ok(v) if v.is_empty() => format!("No city {name} found to remove!"), + Ok(_) => format!("City {name} removed!"), + Err(e) => e.to_string(), } + } -Getting a list of city names is just as easy. The query is just a few words long: +Getting a list of city names is just as easy. The query is just a few word +long: .. code-block:: @@ -566,11 +570,11 @@ use of. The query is a simple ``select``: conditions: { temperature, time } } " -After which we will filter on the name of the ``City``. The method used here is -``.query_required_single()``, because we know that only a single ``City`` can -be returned thanks to the ``exclusive`` constraint on its ``name`` property. -Don't forget that our ``City`` objects already order their weather conditions -by time, so we don't need to do any ordering ourselves: +After which we will filter on the name of the ``City``. The method used here +is ``.query_required_single()``, because we know that only a single ``City`` +can be returned thanks to the ``exclusive`` constraint on its ``name`` +property. Don't forget that our ``City`` objects already order their weather +conditions by time, so we don't need to do any ordering ourselves: .. code-block:: @@ -583,7 +587,9 @@ it into two and thereby get rid of the ``T``. .. code-block:: rust - async fn get_conditions(Path(city_name): Path, State(client): State) -> String { + async fn get_conditions(Path(city_name): Path, + State(client): State) -> String + { let query = select_city("filter .name = $0"); match client .query_required_single::(&query, &(&city_name,)) @@ -592,7 +598,8 @@ it into two and thereby get rid of the ``T``. Ok(city) => { let mut conditions = format!("Conditions for {city_name}:\n\n"); for condition in city.conditions.unwrap_or_default() { - let (date, hour) = condition.time.split_once("T").unwrap_or_default(); + let (date, hour) = condition.time.split_once("T") + .unwrap_or_default(); conditions.push_str(&format!("{date} {hour}\t")); conditions.push_str(&format!("{}\n", condition.temperature)); } @@ -647,7 +654,8 @@ happens if that is the case. .execute(insert_conditions(), &(&name, temperature, time)) .await { - return format!("Inserted City {name} but couldn't insert conditions: {e}"); + return format!("Inserted City {name} \ + but couldn't insert conditions: {e}"); } format!("Inserted city {name}!") } @@ -951,4 +959,6 @@ Here is all of the Rust code: Ok(()) } +.. _in your browser: https://api.open-meteo.com/v1/forecast?latitude=37&longitude=126¤t_weather=true&timezone=CET + .. lint-on \ No newline at end of file From 1cc57b0356305fb59bfe5584b4087aecebc896dc Mon Sep 17 00:00:00 2001 From: Dhghomon Date: Sun, 4 Feb 2024 10:13:28 +0900 Subject: [PATCH 4/6] Add some tips --- docs/guides/tutorials/rust_axum.rst | 131 +++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 14 deletions(-) diff --git a/docs/guides/tutorials/rust_axum.rst b/docs/guides/tutorials/rust_axum.rst index 9f73c56aab4..4c9a1a6cc9e 100644 --- a/docs/guides/tutorials/rust_axum.rst +++ b/docs/guides/tutorials/rust_axum.rst @@ -7,17 +7,18 @@ in Rust that uses Axum as its web server and EdgeDB to hold weather data. The app itself simply calls into a service called Open-Meteo once a minute to look for updated weather information on the cities in the database, and goes back to sleep once it is done. Open-Meteo is being used here because -their service doesn't require any sort of registration. Give it a try -`in your browser`_! +`their service `_ doesn't require any sort +of registration. Give it a try `in your browser`_! We'll be saving the time +and temperature information from this output to the database. Getting started --------------- -To get started, first type ``cargo new weather_app``, or whatever name -you would like to give the project. Go into the directory that was created -and type ``edgedb project init`` to start an EdgeDB instance. Inside, you -will see your schema inside the ``default.esdl`` in the ``/dbschema`` -directory. +To get started, first create a new Cargo project with +``cargo new weather_app``, or whatever name you would like to call it. +Go into the directory that was created and type ``edgedb project init`` +to start an EdgeDB instance. Inside, you will see your schema inside +the ``default.esdl`` in the ``/dbschema`` directory. Schema ------ @@ -25,7 +26,7 @@ Schema The schema is simple but leverages a lot of EdgeDB's guarantees so that we don't have to think about them on the client side. -.. code-block:: edgeql +.. code-block:: sdl module default { @@ -66,7 +67,7 @@ we don't have to think about them on the client side. Let's go over some of the advantages EdgeDB gives us even in a schema as simple as this one. -First are the three scalar types have been extended to give us some type +First are the three scalar types extend ``float64`` to give us some type safety when it comes to latitude, longitude, and temperature. Latitude can't exceed 90 degrees on Earth, and longitude can't exceed 180. Open-Meteo does its own checks for latitude and longitude when querying the conditions for a @@ -77,13 +78,17 @@ are allowed and which are not. Plus, sometimes another server's data will just go haywire for some reason or another, such as the time a weather map showed a high of `thousands of degrees `_ for -various cities in Arizona. With the constraints in place, we are at least +various cities in Arizona. If our database simply accepted anything, we might +end up with some weird outlying numbers that affect any calculations we +make on the data. + +With the constraints in place, we are at least guaranteed to not add temperature data that reaches that point! We'll go with a maximum of 70.0 and low of 100.0 degrees. (The highest and lowest temperatures over recorded on Earth are 56.7 °C and -89.2°C, so our constraints provide a lot of room.) -.. code-block:: edgeql +.. code-block:: sdl scalar type Latitude extending float64 { constraint max_value(90.0); @@ -143,7 +148,7 @@ from Open-Meteo!) We can then use this info to insert a type called ``Conditions`` that will look like this: -.. code-block:: edgeql +.. code-block:: sdl type Conditions { required city: City { @@ -169,7 +174,7 @@ will end up seeing four temperatures an hour added for each city. The ``City`` type is pretty simple: -.. code-block:: edgeql +.. code-block:: sdl type City { required name: str { @@ -199,7 +204,7 @@ cities can have the same name. One possibility later on would be to give a a city of the same name that is 0.00001 degrees different from an existing city (i.e. the same city). -.. code-block:: edgeql-diff +.. code-block:: sdl-diff type City { required name: str; @@ -961,4 +966,102 @@ Here is all of the Rust code: .. _in your browser: https://api.open-meteo.com/v1/forecast?latitude=37&longitude=126¤t_weather=true&timezone=CET +.. lint-on + +Let's finish up this guide with two quick tips on how to speed up your +development time when working with JSON, Rust types, and EdgeQL queries. + +Generating structs from JSON and queries from structs +----------------------------------------------------- + +EdgeDB's Rust client does not yet have a query builder, but there are some +ways to speed up some of the manual typing you often need to do to ensure +type safety in Rust. + +Let's say you wanted to put together some structs to incorporate more of this +output from the Open-Meteo endpoint that we have been using: + +.. code-block:: + + { + "latitude": 49.9375, + "longitude": 50, + "generationtime_ms": 0.06604194641113281, + "utc_offset_seconds": 3600, + "timezone": "Europe/Paris", + "timezone_abbreviation": "CET", + "elevation": 6, + "current_weather_units": { + "time": "iso8601", + "interval": "seconds", + "temperature": "°C", + "windspeed": "km/h", + "winddirection": "°", + "is_day": "", + "weathercode": "wmo code" + }, + "current_weather": { + "time": "2024-02-07T01:00", + "interval": 900, + "temperature": -3.7, + "windspeed": 38.9, + "winddirection": 289, + "is_day": 0, + "weathercode": 3 + } + } + +This will require up to three structs, and is a bit tedious to type. +To speed up the process, simply paste the JSON into your IDE using the +rust-analyzer extension. A lightbulb icon should pop up that offers to +turn the JSON into matching structs. If you click on the icon, the JSON +will turn into the following code: + +.. code-block:: rust + + #[derive(Serialize, Deserialize)] + struct Struct2 { + interval: i64, + is_day: i64, + temperature: f64, + time: String, + weathercode: i64, + winddirection: i64, + windspeed: f64, + } + #[derive(Serialize, Deserialize)] + struct Struct3 { + interval: String, + is_day: String, + temperature: String, + time: String, + weathercode: String, + winddirection: String, + windspeed: String, + } + #[derive(Serialize, Deserialize)] + struct Struct1 { + current_weather: Struct2, + current_weather_units: Struct3, + elevation: i64, + generationtime_ms: f64, + latitude: f64, + longitude: i64, + timezone: String, + timezone_abbreviation: String, + utc_offset_seconds: i64, + } + +With this, the only remaining work is to name the structs and made some +decisions on where to choose a different type from the automatically +generated parameters. The ``time`` parameter for example can be turned +into a ``LocalDatetime`` instead of a ``String``. + +.. lint-off + +Conversely, the unofficial +`edgedb-query-derive ` +crate provides a way to turn Rust types into EdgeQL queries using its +``.to_edge_query()`` method. + .. lint-on \ No newline at end of file From c42d2ce59208b6622cab64368c80d6991b7f235f Mon Sep 17 00:00:00 2001 From: Dhghomon Date: Wed, 7 Feb 2024 11:21:56 +0900 Subject: [PATCH 5/6] Proofread guide --- docs/guides/tutorials/rust_axum.rst | 178 ++++++++++++++-------------- 1 file changed, 88 insertions(+), 90 deletions(-) diff --git a/docs/guides/tutorials/rust_axum.rst b/docs/guides/tutorials/rust_axum.rst index 4c9a1a6cc9e..35f911ab909 100644 --- a/docs/guides/tutorials/rust_axum.rst +++ b/docs/guides/tutorials/rust_axum.rst @@ -2,14 +2,16 @@ Rust Axum ========= -This guide will show you step by step how to create a small working app -in Rust that uses Axum as its web server and EdgeDB to hold weather data. -The app itself simply calls into a service called Open-Meteo once a minute -to look for updated weather information on the cities in the database, and -goes back to sleep once it is done. Open-Meteo is being used here because +This guide will show you step by step how to create a small app in Rust +that uses Axum as its web server and EdgeDB to hold weather data. The app +itself simply calls into the Open-Meteo API to look for updated weather +information on the cities in the database, and goes back to sleep for a +minute every time it finishes looking for updates. + +Open-Meteo is being used here because `their service `_ doesn't require any sort -of registration. Give it a try `in your browser`_! We'll be saving the time -and temperature information from this output to the database. +of registration or API key. Give it a try `in your browser`_! We'll be +saving the time and temperature information from this output to the database. Getting started --------------- @@ -23,8 +25,9 @@ the ``default.esdl`` in the ``/dbschema`` directory. Schema ------ -The schema is simple but leverages a lot of EdgeDB's guarantees so that -we don't have to think about them on the client side. +The schema inside ``default.esdl`` is simple but leverages a lot of EdgeDB's +guarantees so that we don't have to think about them on the client side. Here +is what the final schema will look like: .. code-block:: sdl @@ -64,29 +67,26 @@ we don't have to think about them on the client side. } } -Let's go over some of the advantages EdgeDB gives us even in a schema as -simple as this one. +Let's go over it one part at a time to see what advantages EdgeDB has +given us even in a schema as simple as this one. First are the three scalar types extend ``float64`` to give us some type safety when it comes to latitude, longitude, and temperature. Latitude can't -exceed 90 degrees on Earth, and longitude can't exceed 180. Open-Meteo does -its own checks for latitude and longitude when querying the conditions for a -location, but we might decide to switch to a different weather service one day -and having constraints up front makes it easy for users to see which values -are allowed and which are not. - -Plus, sometimes another server's data will just go haywire for some reason -or another, such as the time a weather map showed a high of -`thousands of degrees `_ for -various cities in Arizona. If our database simply accepted anything, we might -end up with some weird outlying numbers that affect any calculations we -make on the data. - -With the constraints in place, we are at least -guaranteed to not add temperature data that reaches that point! We'll go -with a maximum of 70.0 and low of 100.0 degrees. (The highest and lowest -temperatures over recorded on Earth are 56.7 °C and -89.2°C, so our -constraints provide a lot of room.) +exceed 90 degrees, and longitude can't exceed 180. Open-Meteo does its own +checks for latitude and longitude when querying the conditions for a location, +but we might decide to switch to a different weather service one day +and having constraints in our schema up front makes it easy for users to see +which values are valid and which are not. + +On top of this, sometimes another server's data will just go haywire for some +reason or another, such as the time a weather map showed a high of +`thousands of degrees `_ (!) for +various cities in Arizona. If our database simply accepted any and all output, +we might end up with some weird outlying numbers that affect any calculations +we make on the data. With the constraints in place, we are at least guaranteed +to not add temperature data that reaches that point! The highest and lowest +temperatures ever recorded on Earth are 56.7 °C and -89.2°C, so 70.0 and +-100.00 should be a good range for our ``Temperature`` scalar type. .. code-block:: sdl @@ -106,8 +106,8 @@ constraints provide a lot of room.) } Open-Meteo returns a good deal of information when you query it for current -weather. If you clicked on the link above, you will have seen an output that -looks like the following: +weather. The endpoint in the link above produces an output that looks like +this: .. code-block:: @@ -139,11 +139,10 @@ looks like the following: } } - -But we only need the ``time`` and ``temperature`` located inside -``current_weather``. (Small challenge: feel free to grow the schema with -other scalar types to incorporate all the other information returned -from Open-Meteo!) +To keep the weather app simple, we will only use ``time`` and ``temperature`` +located inside ``current_weather``. (Small challenge: feel free to grow the +schema with other scalar types to incorporate all the other information +returned from Open-Meteo!) We can then use this info to insert a type called ``Conditions`` that will look like this: @@ -167,10 +166,11 @@ source`` so that any time a ``City`` object is deleted, all of the now useless ``Conditions`` objects get deleted along with it. This type also contains an ``exclusive`` constraint on time and city, because -the app will continue to query Open-Meteo for data but shouldn't insert a -``Conditions`` object for a city and time that has already been inserted. In -Open-Meteo's case, these weather conditions are updated every 15 minutes so we -will end up seeing four temperatures an hour added for each city. +the app will continue to query Open-Meteo once a minute for data but shouldn't +insert a ``Conditions`` object for a city and time that has already been +inserted. In Open-Meteo's case, these weather conditions are updated every +15 minutes, so we will end up seeing four temperatures an hour added for +each city. The ``City`` type is pretty simple: @@ -189,20 +189,20 @@ The line with ``multi conditions := (select . Date: Wed, 7 Feb 2024 11:43:26 +0900 Subject: [PATCH 6/6] formatting --- docs/guides/tutorials/rust_axum.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/guides/tutorials/rust_axum.rst b/docs/guides/tutorials/rust_axum.rst index 35f911ab909..63528013ef8 100644 --- a/docs/guides/tutorials/rust_axum.rst +++ b/docs/guides/tutorials/rust_axum.rst @@ -206,14 +206,14 @@ an existing city (i.e. the same location). .. code-block:: sdl-diff - type City { - required name: str; - required latitude: Latitude; - required longitude: Longitude; - multi conditions := (select ..latitude ++ .longitude; - + constraint exclusive on (.key); - } + type City { + required name: str; + required latitude: Latitude; + required longitude: Longitude; + multi conditions := (select ..latitude ++ .longitude; + + constraint exclusive on (.key); + } You could give this or another method a try if you are feeling ambitious. @@ -1058,7 +1058,7 @@ into a ``LocalDatetime`` instead of a ``String``. .. lint-off Conversely, the unofficial -`edgedb-query-derive ` +`edgedb-query-derive `_ crate provides a way to turn Rust types into EdgeQL queries using its ``.to_edge_query()`` method.