From d821851fd8bed077564275a87e9ca67a4af3c70c Mon Sep 17 00:00:00 2001 From: Sam Rijs Date: Sat, 27 Jan 2018 20:47:12 +1100 Subject: [PATCH] html frontend --- Cargo.toml | 1 + assets/static/style.css | 70 +++++++++++++ src/api.rs | 167 -------------------------------- src/assets.rs | 4 - src/main.rs | 11 ++- src/models/repo.rs | 4 + src/server/assets.rs | 9 ++ src/server/mod.rs | 140 ++++++++++++++++++++++++++ src/server/views/mod.rs | 8 ++ src/server/views/status_html.rs | 97 +++++++++++++++++++ src/server/views/status_json.rs | 60 ++++++++++++ src/server/views/status_svg.rs | 16 +++ 12 files changed, 411 insertions(+), 176 deletions(-) create mode 100644 assets/static/style.css delete mode 100644 src/api.rs delete mode 100644 src/assets.rs create mode 100644 src/server/assets.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/views/mod.rs create mode 100644 src/server/views/status_html.rs create mode 100644 src/server/views/status_json.rs create mode 100644 src/server/views/status_svg.rs diff --git a/Cargo.toml b/Cargo.toml index bfe3eb9..784280f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ authors = ["Sam Rijs "] futures = "0.1.18" hyper = "0.11.15" hyper-tls = "0.1.2" +maud = "0.17.2" route-recognizer = "0.1.12" semver = { version = "0.9.0", features = ["serde"] } serde = "1.0.27" diff --git a/assets/static/style.css b/assets/static/style.css new file mode 100644 index 0000000..2a73aed --- /dev/null +++ b/assets/static/style.css @@ -0,0 +1,70 @@ +@import url('https://fonts.googleapis.com/css?family=Fira+Sans:400,500,600,700'); +@import url('https://fonts.googleapis.com/css?family=Source+Code+Pro:400,600'); + +html { + padding: 0; + margin: 0; + background: #eee; + font-family: "Fira Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +body { + padding: 30px 40px; + margin: 0 auto; + background: #fff; + border-left: 5px solid #aaa; + border-right: 5px solid #aaa; + width: 800px; +} + +pre { + font-family: "Source Code Pro", Menlo, Monaco, Consolas, "DejaVu Sans Mono", Inconsolata, monospace; + white-space: pre-wrap; + background: #dedede; + color: #555; + padding: .5em .3em; +} + +code { + font-family: "Source Code Pro", Menlo, Monaco, Consolas, "DejaVu Sans Mono", Inconsolata, monospace; + background: #dedede; + padding: .1em .3em; + border-radius: .3em; + color: #555; +} + +h1 code { + letter-spacing: -0.02em; +} + +table { + width: 100%; + border: 0; + border-spacing: 0; +} + +tr:nth-child(even) { + background: #f3f3f3; +} + +tr:nth-child(odd) { + background: #fff; +} + +td { + padding: .5em .7em; +} + +span.status { + padding: .1em .3em; + border-radius: .3em; + color: #fff; +} + +span.status.up-to-date { + background: #97ca00; +} + +span.status.outdated { + background: #dfb317; +} diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index e97c83b..0000000 --- a/src/api.rs +++ /dev/null @@ -1,167 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::Arc; - -use futures::{Future, IntoFuture, future}; -use hyper::{Error as HyperError, Method, Request, Response, StatusCode}; -use hyper::header::ContentType; -use route_recognizer::{Params, Router}; -use semver::{Version, VersionReq}; -use serde_json; -use slog::Logger; -use tokio_service::Service; - -use ::assets; -use ::engine::{Engine, AnalyzeDependenciesOutcome}; -use ::models::repo::RepoPath; - -#[derive(Clone, Copy)] -enum StatusFormat { - Json, - Svg -} - -enum Route { - Status(StatusFormat) -} - -#[derive(Clone)] -pub struct Api { - engine: Engine, - router: Arc> -} - -impl Api { - pub fn new(engine: Engine) -> Api { - let mut router = Router::new(); - router.add("/repo/:site/:qual/:name/status.json", Route::Status(StatusFormat::Json)); - router.add("/repo/:site/:qual/:name/status.svg", Route::Status(StatusFormat::Svg)); - - Api { engine, router: Arc::new(router) } - } -} - -#[derive(Debug, Serialize)] -struct AnalyzeDependenciesResponseDetail { - required: VersionReq, - latest: Option, - outdated: bool -} - -#[derive(Debug, Serialize)] -struct AnalyzeDependenciesResponseSingle { - dependencies: BTreeMap, - #[serde(rename="dev-dependencies")] - dev_dependencies: BTreeMap, - #[serde(rename="build-dependencies")] - build_dependencies: BTreeMap -} - -#[derive(Debug, Serialize)] -struct AnalyzeDependenciesResponse { - crates: BTreeMap -} - -impl Service for Api { - type Request = Request; - type Response = Response; - type Error = HyperError; - type Future = Box>; - - fn call(&self, req: Request) -> Self::Future { - if let Ok(route_match) = self.router.recognize(req.uri().path()) { - match route_match.handler { - &Route::Status(format) => { - if *req.method() == Method::Get { - return Box::new(self.status(req, route_match.params, format)); - } - } - } - } - - let mut response = Response::new(); - response.set_status(StatusCode::NotFound); - Box::new(future::ok(response)) - } -} - -impl Api { - fn status(&self, _req: Request, params: Params, format: StatusFormat) -> - impl Future - { - let engine = self.engine.clone(); - - let site = params.find("site").expect("route param 'site' not found"); - let qual = params.find("qual").expect("route param 'qual' not found"); - let name = params.find("name").expect("route param 'name' not found"); - - RepoPath::from_parts(site, qual, name).into_future().then(move |repo_path_result| { - match repo_path_result { - Err(err) => { - let mut response = Response::new(); - response.set_status(StatusCode::BadRequest); - response.set_body(format!("{:?}", err)); - future::Either::A(future::ok(response)) - }, - Ok(repo_path) => { - future::Either::B(engine.analyze_dependencies(repo_path).then(move |analyze_result| { - match analyze_result { - Err(err) => { - let mut response = Response::new(); - response.set_status(StatusCode::InternalServerError); - response.set_body(format!("{:?}", err)); - future::Either::A(future::ok(response)) - }, - Ok(analysis_outcome) => { - let response = Api::status_format_analysis(analysis_outcome, format); - future::Either::B(future::ok(response)) - } - } - })) - } - } - }) - } - - fn status_format_analysis(analysis_outcome: AnalyzeDependenciesOutcome, format: StatusFormat) -> Response { - match format { - StatusFormat::Json => { - let single = AnalyzeDependenciesResponseSingle { - dependencies: analysis_outcome.deps.main.into_iter() - .map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail { - outdated: analyzed.is_outdated(), - required: analyzed.required, - latest: analyzed.latest - })).collect(), - dev_dependencies: analysis_outcome.deps.dev.into_iter() - .map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail { - outdated: analyzed.is_outdated(), - required: analyzed.required, - latest: analyzed.latest - })).collect(), - build_dependencies: analysis_outcome.deps.build.into_iter() - .map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail { - outdated: analyzed.is_outdated(), - required: analyzed.required, - latest: analyzed.latest - })).collect() - }; - let multi = AnalyzeDependenciesResponse { - crates: vec![(analysis_outcome.name.into(), single)].into_iter().collect() - }; - Response::new() - .with_header(ContentType::json()) - .with_body(serde_json::to_string(&multi).unwrap()) - }, - StatusFormat::Svg => { - let mut response = Response::new() - .with_header(ContentType("image/svg+xml;charset=utf-8".parse().unwrap())); - if analysis_outcome.deps.any_outdated() { - response.set_body(assets::BADGE_OUTDATED_SVG.to_vec()); - } else { - response.set_body(assets::BADGE_UPTODATE_SVG.to_vec()); - } - response - } - } - } -} \ No newline at end of file diff --git a/src/assets.rs b/src/assets.rs deleted file mode 100644 index 6d23242..0000000 --- a/src/assets.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub static BADGE_UPTODATE_SVG: &'static [u8; 978] = - include_bytes!("../assets/badges/up-to-date.svg"); -pub static BADGE_OUTDATED_SVG: &'static [u8; 974] = - include_bytes!("../assets/badges/outdated.svg"); diff --git a/src/main.rs b/src/main.rs index 2457794..bae1a94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ #![feature(ascii_ctype)] #![feature(conservative_impl_trait)] +#![feature(proc_macro)] extern crate futures; extern crate hyper; extern crate hyper_tls; +extern crate maud; extern crate route_recognizer; extern crate semver; #[macro_use] extern crate serde_derive; @@ -19,8 +21,7 @@ mod models; mod parsers; mod interactors; mod engine; -mod assets; -mod api; +mod server; use std::net::SocketAddr; use std::sync::Mutex; @@ -32,7 +33,7 @@ use hyper_tls::HttpsConnector; use slog::Drain; use tokio_core::reactor::Core; -use self::api::Api; +use self::server::Server; use self::engine::Engine; fn main() { @@ -63,9 +64,9 @@ fn main() { logger: logger.clone() }; - let api = Api::new(engine); + let server = Server::new(engine); - let serve = http.serve_addr_handle(&addr, &handle, move || Ok(api.clone())) + let serve = http.serve_addr_handle(&addr, &handle, move || Ok(server.clone())) .expect("failed to bind server"); let serving = serve.for_each(move |conn| { diff --git a/src/models/repo.rs b/src/models/repo.rs index 1727173..db5617c 100644 --- a/src/models/repo.rs +++ b/src/models/repo.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +#[derive(Clone)] pub struct RepoPath { pub site: RepoSite, pub qual: RepoQualifier, @@ -19,6 +20,7 @@ impl RepoPath { #[derive(Debug)] pub struct RepoValidationError; +#[derive(Clone)] pub struct RepoSite(String); impl FromStr for RepoSite { @@ -43,6 +45,7 @@ impl AsRef for RepoSite { } } +#[derive(Clone)] pub struct RepoQualifier(String); impl FromStr for RepoQualifier { @@ -67,6 +70,7 @@ impl AsRef for RepoQualifier { } } +#[derive(Clone)] pub struct RepoName(String); impl FromStr for RepoName { diff --git a/src/server/assets.rs b/src/server/assets.rs new file mode 100644 index 0000000..7550b43 --- /dev/null +++ b/src/server/assets.rs @@ -0,0 +1,9 @@ +//pub mod templates; + +pub static BADGE_UPTODATE_SVG: &'static [u8; 978] = + include_bytes!("../../assets/badges/up-to-date.svg"); +pub static BADGE_OUTDATED_SVG: &'static [u8; 974] = + include_bytes!("../../assets/badges/outdated.svg"); + +pub static STATIC_STYLE_CSS: &'static str = + include_str!("../../assets/static/style.css"); diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..e587554 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,140 @@ +use std::sync::Arc; + +use futures::{Future, IntoFuture, future}; +use hyper::{Error as HyperError, Method, Request, Response, StatusCode}; +use hyper::header::ContentType; +use route_recognizer::{Params, Router}; +use slog::Logger; +use tokio_service::Service; + +mod assets; +mod views; + +use ::engine::{Engine, AnalyzeDependenciesOutcome}; +use ::models::repo::RepoPath; + +#[derive(Clone, Copy)] +enum StatusFormat { + Html, + Json, + Svg +} + +#[derive(Clone, Copy)] +enum StaticFile { + StyleCss +} + +enum Route { + Static(StaticFile), + Status(StatusFormat) +} + +#[derive(Clone)] +pub struct Server { + engine: Engine, + router: Arc> +} + +impl Server { + pub fn new(engine: Engine) -> Server { + let mut router = Router::new(); + + router.add("/static/style.css", Route::Static(StaticFile::StyleCss)); + + router.add("/repo/:site/:qual/:name", Route::Status(StatusFormat::Html)); + router.add("/repo/:site/:qual/:name/status.json", Route::Status(StatusFormat::Json)); + router.add("/repo/:site/:qual/:name/status.svg", Route::Status(StatusFormat::Svg)); + + Server { engine, router: Arc::new(router) } + } +} + +impl Service for Server { + type Request = Request; + type Response = Response; + type Error = HyperError; + type Future = Box>; + + fn call(&self, req: Request) -> Self::Future { + if let Ok(route_match) = self.router.recognize(req.uri().path()) { + match route_match.handler { + &Route::Status(format) => { + if *req.method() == Method::Get { + return Box::new(self.status(req, route_match.params, format)); + } + }, + &Route::Static(file) => { + if *req.method() == Method::Get { + return Box::new(future::ok(Server::static_file(file))); + } + } + + } + } + + let mut response = Response::new(); + response.set_status(StatusCode::NotFound); + Box::new(future::ok(response)) + } +} + +impl Server { + fn status(&self, _req: Request, params: Params, format: StatusFormat) -> + impl Future + { + let server = self.clone(); + + let site = params.find("site").expect("route param 'site' not found"); + let qual = params.find("qual").expect("route param 'qual' not found"); + let name = params.find("name").expect("route param 'name' not found"); + + RepoPath::from_parts(site, qual, name).into_future().then(move |repo_path_result| { + match repo_path_result { + Err(err) => { + let mut response = Response::new(); + response.set_status(StatusCode::BadRequest); + response.set_body(format!("{:?}", err)); + future::Either::A(future::ok(response)) + }, + Ok(repo_path) => { + future::Either::B(server.engine.analyze_dependencies(repo_path.clone()).then(move |analyze_result| { + match analyze_result { + Err(err) => { + let mut response = Response::new(); + response.set_status(StatusCode::InternalServerError); + response.set_body(format!("{:?}", err)); + future::Either::A(future::ok(response)) + }, + Ok(analysis_outcome) => { + let response = Server::status_format_analysis(analysis_outcome, format, repo_path); + future::Either::B(future::ok(response)) + } + } + })) + } + } + }) + } + + fn status_format_analysis(analysis_outcome: AnalyzeDependenciesOutcome, format: StatusFormat, repo_path: RepoPath) -> Response { + match format { + StatusFormat::Json => + views::status_json(analysis_outcome), + StatusFormat::Svg => + views::status_svg(analysis_outcome), + StatusFormat::Html => + views::status_html(analysis_outcome, repo_path) + } + } + + fn static_file(file: StaticFile) -> Response { + match file { + StaticFile::StyleCss => { + Response::new() + .with_header(ContentType("text/css".parse().unwrap())) + .with_body(assets::STATIC_STYLE_CSS) + } + } + } +} diff --git a/src/server/views/mod.rs b/src/server/views/mod.rs new file mode 100644 index 0000000..031ca34 --- /dev/null +++ b/src/server/views/mod.rs @@ -0,0 +1,8 @@ +mod status_html; +pub use self::status_html::status_html; + +mod status_json; +pub use self::status_json::status_json; + +mod status_svg; +pub use self::status_svg::status_svg; diff --git a/src/server/views/status_html.rs b/src/server/views/status_html.rs new file mode 100644 index 0000000..d4edc50 --- /dev/null +++ b/src/server/views/status_html.rs @@ -0,0 +1,97 @@ +use hyper::Response; +use hyper::header::ContentType; +use maud::{Markup, html}; + +use ::engine::AnalyzeDependenciesOutcome; +use ::models::crates::{CrateName, AnalyzedDependency}; +use ::models::repo::RepoPath; + +const SELF_BASE_URL: &'static str = "http://example.com"; + +fn dependency_table>(deps: I) -> Markup { + html! { + table { + tr { + th "Crate" + th "Required" + th "Latest" + th "Status" + } + @for (name, dep) in deps { + tr { + td { + a href=(format!("https://crates.io/crates/{}", name.as_ref())) (name.as_ref()) + } + td code (dep.required.to_string()) + td { + @if let Some(ref latest) = dep.latest { + code (latest.to_string()) + } @else { + "N/A" + } + } + td { + @if dep.is_outdated() { + span class="status outdated" "out of date" + } @else { + span class="status up-to-date" "up to date" + } + } + } + } + } + } +} + +pub fn status_html(analysis_outcome: AnalyzeDependenciesOutcome, repo_path: RepoPath) -> Response { + let self_path = format!("repo/{}/{}/{}", repo_path.site.as_ref(), repo_path.qual.as_ref(), repo_path.name.as_ref()); + let status_base_url = format!("{}/{}", SELF_BASE_URL, self_path); + let title = format!("{} / {} - Dependency Status", repo_path.qual.as_ref(), repo_path.name.as_ref()); + + let rendered = html! { + html { + head { + title (title) + link rel="stylesheet" type="text/css" href="/static/style.css"; + } + body { + header { + h1 { + "Dependency status for " + code (format!("{}/{}", repo_path.qual.as_ref(), repo_path.name.as_ref())) + } + + h2 { + "Crate " + code (analysis_outcome.name.as_ref()) + } + + img src=(format!("/{}/status.svg", self_path)); + + pre { + (format!("[![dependency status]({}/status.svg)]({})", status_base_url, status_base_url)) + } + + @if !analysis_outcome.deps.main.is_empty() { + h3 "Dependencies" + (dependency_table(analysis_outcome.deps.main)) + } + + @if !analysis_outcome.deps.dev.is_empty() { + h3 "Dev dependencies" + (dependency_table(analysis_outcome.deps.dev)) + } + + @if !analysis_outcome.deps.build.is_empty() { + h3 "Build dependencies" + (dependency_table(analysis_outcome.deps.build)) + } + } + } + } + }; + + Response::new() + .with_header(ContentType::html()) + .with_body(rendered.0) +} diff --git a/src/server/views/status_json.rs b/src/server/views/status_json.rs new file mode 100644 index 0000000..bae807b --- /dev/null +++ b/src/server/views/status_json.rs @@ -0,0 +1,60 @@ +use std::collections::BTreeMap; + +use hyper::Response; +use hyper::header::ContentType; +use semver::{Version, VersionReq}; +use serde_json; + +use ::engine::AnalyzeDependenciesOutcome; + +#[derive(Debug, Serialize)] +struct AnalyzeDependenciesResponseDetail { + required: VersionReq, + latest: Option, + outdated: bool +} + +#[derive(Debug, Serialize)] +struct AnalyzeDependenciesResponseSingle { + dependencies: BTreeMap, + #[serde(rename="dev-dependencies")] + dev_dependencies: BTreeMap, + #[serde(rename="build-dependencies")] + build_dependencies: BTreeMap +} + +#[derive(Debug, Serialize)] +struct AnalyzeDependenciesResponse { + crates: BTreeMap +} + +pub fn status_json(analysis_outcome: AnalyzeDependenciesOutcome) -> Response { + let single = AnalyzeDependenciesResponseSingle { + dependencies: analysis_outcome.deps.main.into_iter() + .map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail { + outdated: analyzed.is_outdated(), + required: analyzed.required, + latest: analyzed.latest + })).collect(), + dev_dependencies: analysis_outcome.deps.dev.into_iter() + .map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail { + outdated: analyzed.is_outdated(), + required: analyzed.required, + latest: analyzed.latest + })).collect(), + build_dependencies: analysis_outcome.deps.build.into_iter() + .map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail { + outdated: analyzed.is_outdated(), + required: analyzed.required, + latest: analyzed.latest + })).collect() + }; + + let multi = AnalyzeDependenciesResponse { + crates: vec![(analysis_outcome.name.into(), single)].into_iter().collect() + }; + + Response::new() + .with_header(ContentType::json()) + .with_body(serde_json::to_string(&multi).unwrap()) +} diff --git a/src/server/views/status_svg.rs b/src/server/views/status_svg.rs new file mode 100644 index 0000000..e25fc93 --- /dev/null +++ b/src/server/views/status_svg.rs @@ -0,0 +1,16 @@ +use hyper::Response; +use hyper::header::ContentType; + +use ::server::assets; +use ::engine::AnalyzeDependenciesOutcome; + +pub fn status_svg(analysis_outcome: AnalyzeDependenciesOutcome) -> Response { + let mut response = Response::new() + .with_header(ContentType("image/svg+xml;charset=utf-8".parse().unwrap())); + if analysis_outcome.deps.any_outdated() { + response.set_body(assets::BADGE_OUTDATED_SVG.to_vec()); + } else { + response.set_body(assets::BADGE_UPTODATE_SVG.to_vec()); + } + response +}