From dff0f8d6e301f2af1e449557715ef8d01ac28f07 Mon Sep 17 00:00:00 2001 From: Sam Rijs Date: Sat, 3 Feb 2018 20:12:00 +1100 Subject: [PATCH] add first prototype of landing page --- src/engine/mod.rs | 10 ++- src/interactors/github.rs | 62 +++++++++++++- src/models/repo.rs | 6 ++ src/server/mod.rs | 25 ++++++ src/server/views/html/index.rs | 54 ++++++++++++ src/server/views/html/mod.rs | 17 +++- src/server/views/html/status.rs | 3 +- src/server/views/status_html.rs | 145 -------------------------------- 8 files changed, 173 insertions(+), 149 deletions(-) create mode 100644 src/server/views/html/index.rs delete mode 100644 src/server/views/status_html.rs diff --git a/src/engine/mod.rs b/src/engine/mod.rs index f559f0a..f5c531f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -6,13 +6,15 @@ use hyper::client::HttpConnector; use hyper_tls::HttpsConnector; use slog::Logger; -use ::models::repo::RepoPath; +use ::models::repo::{Repository, RepoPath}; use ::models::crates::{CrateName, CrateRelease, CrateManifest, AnalyzedDependencies}; use ::parsers::manifest::{ManifestParseError, parse_manifest_toml}; use ::interactors::crates::{QueryCrateError, query_crate}; use ::interactors::github::{RetrieveFileAtPathError, retrieve_file_at_path}; +use ::interactors::github::get_popular_repos; +pub use ::interactors::github::GetPopularReposError; use self::analyzer::DependencyAnalyzer; @@ -37,6 +39,12 @@ pub struct AnalyzeDependenciesOutcome { } impl Engine { + pub fn get_popular_repos(&self) -> + impl Future, Error=GetPopularReposError> + { + get_popular_repos(self.client.clone()) + } + pub fn analyze_dependencies(&self, repo_path: RepoPath) -> impl Future { diff --git a/src/interactors/github.rs b/src/interactors/github.rs index ea74e09..fdb4016 100644 --- a/src/interactors/github.rs +++ b/src/interactors/github.rs @@ -3,10 +3,13 @@ use std::string::FromUtf8Error; use futures::{Future, IntoFuture, Stream, future}; use hyper::{Error as HyperError, Method, Request, Response, StatusCode}; use hyper::error::UriError; +use hyper::header::UserAgent; use tokio_service::Service; +use serde_json; -use ::models::repo::RepoPath; +use ::models::repo::{Repository, RepoPath, RepoValidationError}; +const GITHUB_API_BASE_URI: &'static str = "https://api.github.com"; const GITHUB_USER_CONTENT_BASE_URI: &'static str = "https://raw.githubusercontent.com"; #[derive(Debug)] @@ -44,3 +47,60 @@ pub fn retrieve_file_at_path(service: S, repo_path: &RepoPath, file_path: &st }) }) } + +#[derive(Debug)] +pub enum GetPopularReposError { + Uri(UriError), + Transport(HyperError), + Status(StatusCode), + Decode(serde_json::Error), + Validate(RepoValidationError) +} + +#[derive(Deserialize)] +struct GithubSearchResponse { + items: Vec +} + +#[derive(Deserialize)] +struct GithubRepo { + name: String, + owner: GithubOwner, + description: String +} + +#[derive(Deserialize)] +struct GithubOwner { + login: String +} + +pub fn get_popular_repos(service: S) -> + impl Future, Error=GetPopularReposError> + where S: Service +{ + let uri_future = format!("{}/search/repositories?q=language:rust&sort=stars", GITHUB_API_BASE_URI) + .parse().into_future().map_err(GetPopularReposError::Uri); + + uri_future.and_then(move |uri| { + let mut request = Request::new(Method::Get, uri); + request.headers_mut().set(UserAgent::new("deps.rs")); + + service.call(request).map_err(GetPopularReposError::Transport).and_then(|response| { + let status = response.status(); + if !status.is_success() { + future::Either::A(future::err(GetPopularReposError::Status(status))) + } else { + let body_future = response.body().concat2().map_err(GetPopularReposError::Transport); + let decode_future = body_future + .and_then(|body| serde_json::from_slice(body.as_ref()).map_err(GetPopularReposError::Decode)); + future::Either::B(decode_future.and_then(|search_response: GithubSearchResponse| { + search_response.items.into_iter().map(|item| { + let path = RepoPath::from_parts("github", &item.owner.login, &item.name) + .map_err(GetPopularReposError::Validate)?; + Ok(Repository { path, description: item.description }) + }).collect::, _>>() + })) + } + }) + }) +} diff --git a/src/models/repo.rs b/src/models/repo.rs index 6cd5777..3e29cc6 100644 --- a/src/models/repo.rs +++ b/src/models/repo.rs @@ -1,5 +1,11 @@ use std::str::FromStr; +#[derive(Clone)] +pub struct Repository { + pub path: RepoPath, + pub description: String +} + #[derive(Clone)] pub struct RepoPath { pub site: RepoSite, diff --git a/src/server/mod.rs b/src/server/mod.rs index c94073f..7bc018e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -26,6 +26,7 @@ enum StaticFile { } enum Route { + Index, Static(StaticFile), Status(StatusFormat) } @@ -40,6 +41,8 @@ impl Server { pub fn new(engine: Engine) -> Server { let mut router = Router::new(); + router.add("/", Route::Index); + router.add("/static/style.css", Route::Static(StaticFile::StyleCss)); router.add("/repo/:site/:qual/:name", Route::Status(StatusFormat::Html)); @@ -59,6 +62,11 @@ impl Service for Server { fn call(&self, req: Request) -> Self::Future { if let Ok(route_match) = self.router.recognize(req.uri().path()) { match route_match.handler { + &Route::Index => { + if *req.method() == Method::Get { + return Box::new(self.index(req, route_match.params)); + } + }, &Route::Status(format) => { if *req.method() == Method::Get { return Box::new(self.status(req, route_match.params, format)); @@ -80,6 +88,23 @@ impl Service for Server { } impl Server { + fn index(&self, _req: Request, _params: Params) -> + impl Future + { + self.engine.get_popular_repos().then(|popular_result| { + match popular_result { + Err(err) => { + let mut response = Response::new(); + response.set_status(StatusCode::BadRequest); + response.set_body(format!("{:?}", err)); + future::ok(response) + }, + Ok(popular) => + future::ok(views::html::index::render(popular)) + } + }) + } + fn status(&self, _req: Request, params: Params, format: StatusFormat) -> impl Future { diff --git a/src/server/views/html/index.rs b/src/server/views/html/index.rs new file mode 100644 index 0000000..8f94495 --- /dev/null +++ b/src/server/views/html/index.rs @@ -0,0 +1,54 @@ +use hyper::Response; +use maud::{Markup, html}; + +use ::models::repo::Repository; + +fn popular_table(popular: Vec) -> Markup { + html! { + h2 class="title is-3" "Popular" + + table class="table is-fullwidth is-striped is-hoverable" { + thead { + tr { + th "Repository" + th class="has-text-right" "Status" + } + } + tbody { + @for repo in popular { + tr { + td { + a href=(format!("{}/repo/{}/{}/{}", &super::SELF_BASE_URL as &str, repo.path.site.as_ref(), repo.path.qual.as_ref(), repo.path.name.as_ref())) { + (format!("{} / {}", repo.path.qual.as_ref(), repo.path.name.as_ref())) + } + } + td class="has-text-right" { + img src=(format!("{}/repo/{}/{}/{}/status.svg", &super::SELF_BASE_URL as &str, repo.path.site.as_ref(), repo.path.qual.as_ref(), repo.path.name.as_ref())); + } + } + } + } + } + } +} + +pub fn render(popular: Vec) -> Response { + super::render_html("Keep your dependencies up-to-date - Deps.rs", html! { + section class="hero is-light" { + div class="hero-head" (super::render_navbar()) + div class="hero-body" { + div class="container" { + p class="title is-1" "Keep your dependencies up-to-date" + p { + "Docs.rs uses semantic versioning to detect outdated or insecure dependencies in your project's" + code "Cargo.toml" + "." + } + } + } + } + section class="section" { + div class="container" (popular_table(popular)) + } + }) +} diff --git a/src/server/views/html/mod.rs b/src/server/views/html/mod.rs index ada01ea..d624947 100644 --- a/src/server/views/html/mod.rs +++ b/src/server/views/html/mod.rs @@ -2,8 +2,9 @@ use std::env; use hyper::Response; use hyper::header::ContentType; -use maud::{Render, html}; +use maud::{Markup, Render, html}; +pub mod index; pub mod status; lazy_static! { @@ -35,3 +36,17 @@ fn render_html(title: &str, body: B) -> Response { .with_header(ContentType::html()) .with_body(rendered.0) } + +fn render_navbar() -> Markup { + html! { + header class="navbar" { + div class="container" { + div class="navbar-brand" { + a class="navbar-item is-dark" href=(SELF_BASE_URL) { + h1 class="title is-3" "Deps.rs" + } + } + } + } + } +} diff --git a/src/server/views/html/status.rs b/src/server/views/html/status.rs index 6301977..108d539 100644 --- a/src/server/views/html/status.rs +++ b/src/server/views/html/status.rs @@ -63,7 +63,7 @@ fn dependency_table(title: &str, deps: BTreeMap) pub fn render(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!("{}/{}", &super::SELF_BASE_URL as &str, self_path); - let title = format!("{} / {} - Dependency Status", repo_path.qual.as_ref(), repo_path.name.as_ref()); + let title = format!("{} / {} - Deps.rs", repo_path.qual.as_ref(), repo_path.name.as_ref()); let (hero_class, status_asset) = if analysis_outcome.deps.any_outdated() { ("is-warning", assets::BADGE_OUTDATED_SVG.as_ref()) @@ -75,6 +75,7 @@ pub fn render(analysis_outcome: AnalyzeDependenciesOutcome, repo_path: RepoPath) super::render_html(&title, html! { section class=(format!("hero {}", hero_class)) { + div class="hero-head" (super::render_navbar()) div class="hero-body" { div class="container" { h1 class="title is-1" { diff --git a/src/server/views/status_html.rs b/src/server/views/status_html.rs deleted file mode 100644 index f0b84be..0000000 --- a/src/server/views/status_html.rs +++ /dev/null @@ -1,145 +0,0 @@ -use std::collections::BTreeMap; -use std::env; - -use base64::display::Base64Display; -use hyper::Response; -use hyper::header::ContentType; -use maud::{Markup, html}; - -use ::engine::AnalyzeDependenciesOutcome; -use ::models::crates::{CrateName, AnalyzedDependency}; -use ::models::repo::RepoPath; -use ::server::assets; - -lazy_static! { - static ref SELF_BASE_URL: String = { - env::var("BASE_URL") - .unwrap_or_else(|_| "http://localhost:8080".to_string()) - }; -} - -fn dependency_table(title: &str, deps: BTreeMap) -> Markup { - let count_total = deps.len(); - let count_outdated = deps.iter().filter(|&(_, dep)| dep.is_outdated()).count(); - - html! { - h3 class="title is-4" (title) - p class="subtitle is-5" { - @if count_outdated > 0 { - (format!(" ({} total, {} up-to-date, {} outdated)", count_total, count_total - count_outdated, count_outdated)) - } @else { - (format!(" ({} total, all up-to-date)", count_total)) - } - } - - table class="table is-fullwidth is-striped is-hoverable" { - thead { - tr { - th "Crate" - th class="has-text-right" "Required" - th class="has-text-right" "Latest" - th class="has-text-right" "Status" - } - } - tbody { - @for (name, dep) in deps { - tr { - td { - a href=(format!("https://crates.io/crates/{}", name.as_ref())) (name.as_ref()) - } - td class="has-text-right" code (dep.required.to_string()) - td class="has-text-right" { - @if let Some(ref latest) = dep.latest { - code (latest.to_string()) - } @else { - "N/A" - } - } - td class="has-text-right" { - @if dep.is_outdated() { - span class="tag is-warning" "out of date" - } @else { - span class="tag is-success" "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 as &str, self_path); - let title = format!("{} / {} - Dependency Status", repo_path.qual.as_ref(), repo_path.name.as_ref()); - - let (hero_class, status_asset) = if analysis_outcome.deps.any_outdated() { - ("is-warning", assets::BADGE_OUTDATED_SVG.as_ref()) - } else { - ("is-success", assets::BADGE_UPTODATE_SVG.as_ref()) - }; - - let status_data_url = format!("data:image/svg+xml;base64,{}", Base64Display::standard(status_asset)); - - let rendered = html! { - html { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; - title (title) - link rel="stylesheet" type="text/css" href="/static/style.css"; - link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Fira+Sans:400,500,600"; - link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Source+Code+Pro"; - link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"; - } - body { - section class=(format!("hero {}", hero_class)) { - div class="hero-body" { - div class="container" { - h1 class="title is-1" { - a href=(format!("{}/{}/{}", repo_path.site.to_base_uri(), repo_path.qual.as_ref(), repo_path.name.as_ref())) { - i class="fa fa-github" "" - (format!(" {} / {}", repo_path.qual.as_ref(), repo_path.name.as_ref())) - } - } - - img src=(status_data_url); - } - } - div class="hero-footer" { - div class="container" { - pre class="is-size-7" { - (format!("[![dependency status]({}/status.svg)]({})", status_base_url, status_base_url)) - } - } - } - } - section class="section" { - div class="container" { - h2 class="title is-3" { - "Crate " - code (analysis_outcome.name.as_ref()) - } - - @if !analysis_outcome.deps.main.is_empty() { - (dependency_table("Dependencies", analysis_outcome.deps.main)) - } - - @if !analysis_outcome.deps.dev.is_empty() { - (dependency_table("Dev dependencies", analysis_outcome.deps.dev)) - } - - @if !analysis_outcome.deps.build.is_empty() { - (dependency_table("Build dependencies", analysis_outcome.deps.build)) - } - } - } - } - } - }; - - Response::new() - .with_header(ContentType::html()) - .with_body(rendered.0) -}