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",
"serde",
"serde_json",
"thiserror",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-logger",

View File

@ -103,7 +103,7 @@ async fn main() {
)
.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);
let server = axum::Server::bind(&addr)

View File

@ -18,3 +18,4 @@ serde.workspace = true
serde_json = "1.0.96"
web-sys = "0.3.63"
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/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 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" />
</head>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</html>

View File

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

View File

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

View File

@ -8,7 +8,7 @@ use yew_agent::Bridged;
use yew_router::scope_ext::RouterScopeExt;
pub struct Root {
wss: WebsocketService,
_wss: WebsocketService,
_producer: Box<dyn Bridge<EventBus>>,
/// None until the first websocket message is received.
@ -32,7 +32,7 @@ impl Component for Root {
};
Self {
wss,
_wss: wss,
_producer: EventBus::bridge(Rc::new(cb)),
state: None,
}
@ -49,7 +49,6 @@ impl Component for Root {
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link().clone();
let nav = ctx.link().navigator().unwrap();
let onclick_create_achievement = Callback::from(move |_: web_sys::MouseEvent| {
@ -75,13 +74,18 @@ impl Component for Root {
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 />
{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;

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 {
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();
@ -32,13 +34,11 @@ impl WebsocketService {
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(data)) => {
log::debug!("from websocket: {}", data);
event_bus.send(EventBusInput::EventBusMsg(data));
}
Ok(Message::Bytes(b)) => {
let decoded = std::str::from_utf8(&b);
if let Ok(val) = decoded {
log::debug!("from websocket: {}", val);
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;
}