RestService

This commit is contained in:
Asger Juul Brunshøj 2023-06-12 15:33:44 +02:00
parent ddd039fead
commit 53a5c8a976
12 changed files with 239 additions and 52 deletions

1
Cargo.lock generated
View File

@ -234,6 +234,7 @@ dependencies = [
"reqwasm", "reqwasm",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-logger", "wasm-logger",

View File

@ -103,7 +103,7 @@ async fn main() {
) )
.layer(CorsLayer::permissive()); .layer(CorsLayer::permissive());
let addr = SocketAddr::from(([127, 0, 0, 1], 4000)); let addr = SocketAddr::from(([0, 0, 0, 0], 4000));
tracing::debug!("listening on {}", addr); tracing::debug!("listening on {}", addr);
let server = axum::Server::bind(&addr) let server = axum::Server::bind(&addr)

View File

@ -18,3 +18,4 @@ serde.workspace = true
serde_json = "1.0.96" serde_json = "1.0.96"
web-sys = "0.3.63" web-sys = "0.3.63"
wasm-bindgen = "0.2.86" wasm-bindgen = "0.2.86"
thiserror = "1.0.40"

View File

@ -11,9 +11,18 @@
<link data-trunk rel="css" href="static/css/normalize.css"> <link data-trunk rel="css" href="static/css/normalize.css">
<link data-trunk rel="css" href="static/css/skeleton.css"> <link data-trunk rel="css" href="static/css/skeleton.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Own styles -->
<link data-trunk rel="css" href="static/css/styles.css">
<!-- Favicon -->
<link rel="icon" type="image/png" href="static/images/favicon.png"> <link rel="icon" type="image/png" href="static/images/favicon.png">
<link data-trunk rel="rust" href="Cargo.toml" data-bin="app" data-type="main" /> <link data-trunk rel="rust" href="Cargo.toml" data-bin="app" data-type="main" />
<link data-trunk rel="rust" href="Cargo.toml" data-bin="event_bus" data-type="worker" /> <link data-trunk rel="rust" href="Cargo.toml" data-bin="event_bus" data-type="worker" />
</head> </head>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</html> </html>

View File

@ -1,9 +1,13 @@
use crate::services::confirm::ConfirmService;
use crate::services::rest::RestService;
use common::Achievement; use common::Achievement;
use common::DeleteAchievement;
use common::ToggleAchievement; use common::ToggleAchievement;
use reqwasm::http::Request;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use yew::classes;
use yew::function_component; use yew::function_component;
use yew::html; use yew::html;
use yew::use_memo;
use yew::Callback; use yew::Callback;
use yew::Html; use yew::Html;
use yew::Properties; use yew::Properties;
@ -21,39 +25,50 @@ pub fn AchievementComponent(props: &Props) -> Html {
uuid, uuid,
} = &props.achievement; } = &props.achievement;
let confirm_service = use_memo(|_| ConfirmService, ());
let uuid = *uuid; let uuid = *uuid;
let onclick = Callback::from(move |_| { let onclick_toggle = Callback::from(move |_| {
log::info!("button click, toggling achievement"); log::info!("button click, toggling achievement");
let payload = ToggleAchievement { uuid };
let payload = serde_json::to_string(&payload).unwrap();
spawn_local(async move { spawn_local(async move {
let req = Request::post("http://127.0.0.1:4000/toggle") match RestService::toggle_achievement(ToggleAchievement { uuid }).await {
.header("Content-Type", "application/json") Ok(_response) => {}
.body(payload); Err(_err) => {}
let res = req.send().await;
match res {
Ok(response) => {
dbg!(response);
}
Err(err) => {
log::error!("Request failed: {err:?}");
}
} }
}); });
}); });
let button_class = if *completed { "button-primary" } else { "button" }; let onclick_delete = Callback::from(move |_| {
if !confirm_service.confirm("Are you sure you want to delete?") {
return;
}
log::info!("Delete achievement confirmed.");
spawn_local(async move {
match RestService::delete_achievement(DeleteAchievement { uuid }).await {
Ok(_response) => {}
Err(_err) => {}
}
});
});
let toggle_button_class = if *completed {
"button-primary color-secondary"
} else {
"button"
};
html! { html! {
<div class="row"> <div class="row flex">
<div class="nine columns"> <div class="flex-intrinsic-size">
<button onclick={onclick_toggle} class={classes!(toggle_button_class)}><i class={classes!("fas", "fa-fw", completed.then_some("fa-star"), (!completed).then_some("fa-star-half-stroke"))} /></button>
</div>
<div class="flex-grow">
<p>{goal}</p> <p>{goal}</p>
</div> </div>
<div class="three columns"> <div class="flex-intrinsic-size">
<button {onclick} class={button_class}>{""}</button> <button onclick={onclick_delete} class="button color-danger"><i class="fas fa-trash"/></button>
</div> </div>
</div> </div>
} }

View File

@ -1,11 +1,13 @@
use crate::services::rest::RestService;
use common::CreateAchievement; use common::CreateAchievement;
use reqwasm::http::Request;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlInputElement;
use yew::html; use yew::html;
use yew::Callback; use yew::Callback;
use yew::Component; use yew::Component;
use yew::Html; use yew::Html;
use yew::NodeRef;
use yew_router::scope_ext::RouterScopeExt; use yew_router::scope_ext::RouterScopeExt;
#[derive(Debug)] #[derive(Debug)]
@ -19,6 +21,7 @@ pub enum Msg {
pub struct CreateAchievementComponent { pub struct CreateAchievementComponent {
input_value: String, input_value: String,
submitted: bool, submitted: bool,
input_ref: NodeRef,
} }
impl Component for CreateAchievementComponent { impl Component for CreateAchievementComponent {
type Message = Msg; type Message = Msg;
@ -28,6 +31,17 @@ impl Component for CreateAchievementComponent {
Self::default() Self::default()
} }
fn rendered(&mut self, _ctx: &yew::Context<Self>, first_render: bool) {
if first_render {
if let Some(input_element) = self.input_ref.get() {
let input_element = input_element
.dyn_into::<HtmlInputElement>()
.expect("Failed to cast input element");
input_element.focus().expect("Failed to focus input");
}
}
}
fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
match msg { match msg {
Msg::Reset => { Msg::Reset => {
@ -36,25 +50,14 @@ impl Component for CreateAchievementComponent {
true true
} }
Msg::Submit => { Msg::Submit => {
log::info!("button click, creating achievement"); log::info!("Creating achievement");
let payload = CreateAchievement { let payload = CreateAchievement {
goal: self.input_value.clone(), goal: self.input_value.clone(),
}; };
let payload = serde_json::to_string(&payload).unwrap();
spawn_local(async move { spawn_local(async move {
let req = Request::post("http://127.0.0.1:4000/create") match RestService::create_achievement(payload).await {
.header("Content-Type", "application/json") Ok(_response) => {}
.body(payload); Err(_err) => {}
let res = req.send().await;
match res {
Ok(response) => {
dbg!(response);
}
Err(err) => {
log::error!("Request failed: {err:?}");
}
} }
}); });
@ -114,8 +117,6 @@ impl Component for CreateAchievementComponent {
Msg::UpdateInput(input.value()) Msg::UpdateInput(input.value())
}); });
let input_value = self.input_value.clone();
html! { html! {
<> <>
<div class="row"> <div class="row">
@ -127,7 +128,7 @@ impl Component for CreateAchievementComponent {
<div class="row"> <div class="row">
<div class="twelve columns"> <div class="twelve columns">
<label for="achievementInput">{"New achievement"}</label> <label for="achievementInput">{"New achievement"}</label>
<input {oninput} value={input_value} class="u-full-width" type="text" id="achievementInput" /> <input ref={self.input_ref.clone()} {oninput} value={self.input_value.clone()} class="u-full-width" type="text" id="achievementInput" />
</div> </div>
</div> </div>
<input class="button-primary" type="submit" value="Submit" /> <input class="button-primary" type="submit" value="Submit" />

View File

@ -8,7 +8,7 @@ use yew_agent::Bridged;
use yew_router::scope_ext::RouterScopeExt; use yew_router::scope_ext::RouterScopeExt;
pub struct Root { pub struct Root {
wss: WebsocketService, _wss: WebsocketService,
_producer: Box<dyn Bridge<EventBus>>, _producer: Box<dyn Bridge<EventBus>>,
/// None until the first websocket message is received. /// None until the first websocket message is received.
@ -32,7 +32,7 @@ impl Component for Root {
}; };
Self { Self {
wss, _wss: wss,
_producer: EventBus::bridge(Rc::new(cb)), _producer: EventBus::bridge(Rc::new(cb)),
state: None, state: None,
} }
@ -49,7 +49,6 @@ impl Component for Root {
} }
fn view(&self, ctx: &Context<Self>) -> Html { fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link().clone();
let nav = ctx.link().navigator().unwrap(); let nav = ctx.link().navigator().unwrap();
let onclick_create_achievement = Callback::from(move |_: web_sys::MouseEvent| { let onclick_create_achievement = Callback::from(move |_: web_sys::MouseEvent| {
@ -75,13 +74,18 @@ impl Component for Root {
html! { html! {
<> <>
<h4>{"Achievements"}</h4> <div class="row flex flex-wrap">
<div class="flex-grow">
<h3>{"Achievements"}</h3>
</div>
<div class="flex-intrinsic-size">
<button onclick={onclick_create_achievement} class="button-primary">{"New achievement"}</button>
</div>
</div>
<hr /> <hr />
{achievements} {achievements}
<hr />
<button onclick={onclick_create_achievement} class="button-primary">{"Create new achievement"}</button>
</> </>
} }
} }

View File

@ -0,0 +1,11 @@
#[derive(Debug, PartialEq, Eq)]
pub struct ConfirmService;
impl ConfirmService {
pub fn confirm(&self, message: &str) -> bool {
web_sys::window()
.expect("no global `window` exists")
.confirm_with_message(message)
.expect("failed to execute `window.confirm`")
}
}

View File

@ -1 +1,3 @@
pub mod confirm;
pub mod rest;
pub mod websocket; pub mod websocket;

View File

@ -0,0 +1,68 @@
use common::CreateAchievement;
use common::DeleteAchievement;
use common::ToggleAchievement;
use reqwasm::http::Request;
use serde::Serialize;
pub struct RestService;
#[derive(thiserror::Error, Debug)]
pub enum RestServiceError {
#[error("Failed to serialize/deserialize data: {0}")]
SerdeError(#[from] serde_json::Error),
#[error("Unexpected response")]
UnexpectedResponse(reqwasm::http::Response),
#[error("Failed request")]
ReqwasmError(#[from] reqwasm::Error),
}
impl RestService {
fn hostname() -> String {
web_sys::window().unwrap().location().hostname().unwrap()
}
fn url_to(endpoint: &str) -> String {
format!("http://{}:4000{endpoint}", Self::hostname())
}
async fn post_json<T: Serialize>(payload: T, path: &str) -> Result<(), RestServiceError> {
let req = Request::post(&Self::url_to(path))
.header("Content-Type", "application/json")
.body(serde_json::to_string(&payload)?);
let res = req.send().await;
match res {
Ok(response) => {
let status_code = response.status();
if (200..300).contains(&status_code) {
Ok(())
} else {
log::warn!(
"Received unexpected status code: {}. Response: {:?}",
status_code,
response
);
Err(RestServiceError::UnexpectedResponse(response))
}
}
Err(err) => {
log::error!("Request failed: {:?}", err);
Err(err.into())
}
}
}
pub async fn toggle_achievement(payload: ToggleAchievement) -> Result<(), RestServiceError> {
Self::post_json(payload, "/toggle").await
}
pub async fn delete_achievement(payload: DeleteAchievement) -> Result<(), RestServiceError> {
Self::post_json(payload, "/delete").await
}
pub async fn create_achievement(payload: CreateAchievement) -> Result<(), RestServiceError> {
Self::post_json(payload, "/create").await
}
}

View File

@ -14,7 +14,9 @@ pub struct WebsocketService {
impl WebsocketService { impl WebsocketService {
pub fn new() -> Self { pub fn new() -> Self {
let ws = WebSocket::open("ws://127.0.0.1:4000/ws").unwrap(); let hostname = web_sys::window().unwrap().location().hostname().unwrap();
let ws_addr = format!("ws://{hostname}:4000/ws");
let ws = WebSocket::open(&ws_addr).unwrap();
let (mut write, mut read) = ws.split(); let (mut write, mut read) = ws.split();
@ -32,13 +34,11 @@ impl WebsocketService {
while let Some(msg) = read.next().await { while let Some(msg) = read.next().await {
match msg { match msg {
Ok(Message::Text(data)) => { Ok(Message::Text(data)) => {
log::debug!("from websocket: {}", data);
event_bus.send(EventBusInput::EventBusMsg(data)); event_bus.send(EventBusInput::EventBusMsg(data));
} }
Ok(Message::Bytes(b)) => { Ok(Message::Bytes(b)) => {
let decoded = std::str::from_utf8(&b); let decoded = std::str::from_utf8(&b);
if let Ok(val) = decoded { if let Ok(val) = decoded {
log::debug!("from websocket: {}", val);
event_bus.send(EventBusInput::EventBusMsg(val.into())); event_bus.send(EventBusInput::EventBusMsg(val.into()));
} }
} }

View File

@ -0,0 +1,75 @@
:root {
--primary-color: #89CFF0; /* Baby Blue */
--secondary-color: #FFB347; /* Pastel Orange */
--accent-color: #77dd77; /* Pastel Green */
--warning-color: #f1c40f;
--danger-color: #e74c3c;
}
.button.color-primary {
color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.button.color-secondary {
color: var(--secondary-color) !important;
border-color: var(--secondary-color) !important;
}
.button.color-accent {
color: var(--accent-color) !important;
border-color: var(--accent-color) !important;
}
.button.color-warning {
color: var(--warning-color) !important;
border-color: var(--warning-color) !important;
}
.button.color-danger {
color: var(--danger-color) !important;
border-color: var(--danger-color) !important;
}
.button-primary.color-primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.button-primary.color-secondary {
background-color: var(--secondary-color) !important;
border-color: var(--secondary-color) !important;
}
.button-primary.color-accent {
background-color: var(--accent-color) !important;
border-color: var(--accent-color) !important;
}
.button-primary.color-warning {
background-color: var(--warning-color) !important;
border-color: var(--warning-color) !important;
}
.button-primary.color-danger {
background-color: var(--danger-color) !important;
border-color: var(--danger-color) !important;
}
.flex {
display: flex;
gap: 20px;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-intrinsic-size {
flex-grow: 0;
flex-shrink: 0;
}
.flex-grow {
flex-grow: 1;
}