├── src ├── pkg ├── react-app-env.d.ts ├── setupTests.ts ├── index.tsx ├── reportWebVitals.ts ├── index-cra.tsx └── App.tsx ├── backend ├── rustfmt.toml ├── .gitignore ├── tests │ └── web.rs ├── src │ ├── utils.rs │ ├── head-query.graphql │ ├── web.rs │ └── lib.rs └── Cargo.toml ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .gitignore ├── tsconfig.json ├── LICENSE ├── craco.config.js ├── package.json └── README.md /src/pkg: -------------------------------------------------------------------------------- 1 | ../backend/pkg/ -------------------------------------------------------------------------------- /backend/rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | pkg/ 6 | wasm-pack.log 7 | .log 8 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adimit/react-wasm-github-api-demo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adimit/react-wasm-github-api-demo/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adimit/react-wasm-github-api-demo/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /backend/tests/web.rs: -------------------------------------------------------------------------------- 1 | //! Test suite for the Web and headless browsers. 2 | 3 | #![cfg(target_arch = "wasm32")] 4 | 5 | extern crate wasm_bindgen_test; 6 | use wasm_bindgen_test::*; 7 | 8 | wasm_bindgen_test_configure!(run_in_browser); 9 | 10 | #[wasm_bindgen_test] 11 | fn pass() { 12 | assert_eq!(1 + 1, 2); 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This bootstrap file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import("./index-cra") 5 | .catch(e => console.error("Error importing `index-cra`:", e)); 6 | 7 | // Need to make this file a module 8 | export {}; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | .log 27 | -------------------------------------------------------------------------------- /backend/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn set_panic_hook() { 2 | // When the `console_error_panic_hook` feature is enabled, we can call the 3 | // `set_panic_hook` function at least once during initialization, and then 4 | // we will get better error messages if our code ever panics. 5 | // 6 | // For more details see 7 | // https://github.com/rustwasm/console_error_panic_hook#readme 8 | #[cfg(feature = "console_error_panic_hook")] 9 | console_error_panic_hook::set_once(); 10 | } 11 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/index-cra.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import reportWebVitals from "./reportWebVitals"; 5 | import { init } from "./pkg"; 6 | 7 | // Initialise Rust-Side logging & panic hooks. Only call this once, otherwise 8 | // Rust will panic. 9 | init(); 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById("root") 16 | ); 17 | 18 | // If you want to start measuring performance in your app, pass a function 19 | // to log results (for example: reportWebVitals(console.log)) 20 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 21 | reportWebVitals(console.log); 22 | -------------------------------------------------------------------------------- /backend/src/head-query.graphql: -------------------------------------------------------------------------------- 1 | query BranchHeadCommitAuthor($owner: String!, $repoName: String!, $branch: String!) { 2 | repository(owner: $owner, name: $repoName) { 3 | nameWithOwner 4 | name 5 | owner { 6 | __typename 7 | login 8 | avatarUrl 9 | ... on User{ 10 | name 11 | email 12 | } 13 | ... on Organization { 14 | name 15 | email 16 | } 17 | } 18 | ref(qualifiedName: $branch) { 19 | id 20 | name 21 | target { 22 | __typename 23 | id 24 | ...on Commit { 25 | oid 26 | message 27 | committer { 28 | name 29 | email 30 | avatarUrl 31 | user { 32 | login 33 | } 34 | } 35 | author { 36 | name 37 | email 38 | avatarUrl 39 | user { 40 | login 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | rateLimit { 48 | cost 49 | limit 50 | nodeCount 51 | remaining 52 | resetAt 53 | used 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Aleksandar Dimitrov 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const WasmPackPlugin = require('@wasm-tool/wasm-pack-plugin'); 3 | 4 | module.exports = { 5 | webpack: { 6 | configure: function override(config, dev) { 7 | /** 8 | * Add WASM support. SO: https://stackoverflow.com/questions/59319775 9 | */ 10 | 11 | // Make file-loader ignore WASM files 12 | const wasmExtensionRegExp = /\.wasm$/; 13 | config.resolve.extensions.push('.wasm'); 14 | config.module.rules.forEach(rule => { 15 | (rule.oneOf || []).forEach(oneOf => { 16 | if (oneOf.loader && oneOf.loader.indexOf('file-loader') >= 0) { 17 | oneOf.exclude.push(wasmExtensionRegExp); 18 | } 19 | }); 20 | }); 21 | 22 | // Add a dedicated loader for WASM 23 | config.module.rules.push({ 24 | test: wasmExtensionRegExp, 25 | include: path.resolve(__dirname, 'src'), 26 | use: [{ loader: require.resolve('wasm-loader'), options: {} }] 27 | }); 28 | 29 | config.plugins.push(new WasmPackPlugin({ 30 | crateDirectory: path.resolve(__dirname, "backend"), 31 | })); 32 | 33 | return config; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-wasm-github-api-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.11.3", 7 | "react": "^17.0.1", 8 | "react-dom": "^17.0.1", 9 | "react-scripts": "4.0.1", 10 | "typescript": "^4.0.3", 11 | "web-vitals": "^0.2.4" 12 | }, 13 | "scripts": { 14 | "download-schema": "apollo client:download-schema --endpoint https://api.github.com/graphql backend/github.graphql --includes=\"backend/src/*.graphql\" --header=\"Authorization: bearer ${GITHUB_API_TOKEN}\"", 15 | "start": "craco start", 16 | "build": "craco build", 17 | "test": "craco test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app", 23 | "react-app/jest" 24 | ] 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "@craco/craco": "^6.1.0", 40 | "@testing-library/jest-dom": "^5.11.4", 41 | "@testing-library/react": "^11.1.0", 42 | "@testing-library/user-event": "^12.1.10", 43 | "@types/jest": "^26.0.15", 44 | "@types/node": "^12.0.0", 45 | "@types/react": "^16.9.53", 46 | "@types/react-dom": "^16.9.8", 47 | "@wasm-tool/wasm-pack-plugin": "^1.3.1", 48 | "apollo": "^2.32.1", 49 | "wasm-loader": "^1.3.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "backend" 3 | version = "0.1.0" 4 | authors = ["Aleksandar Dimitrov "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [features] 11 | default = ["console_error_panic_hook", "wee_alloc", "console_log"] 12 | 13 | [dependencies] 14 | wasm-bindgen = { version = "0.2.70", features = ["serde-serialize"] } 15 | graphql_client = {version = "0.9", features = ["web"] } 16 | js-sys = "0.3" 17 | wasm-bindgen-futures = "0.4.20" 18 | serde = { version = "1.0.80", features = ["derive"] } 19 | serde_derive = "1.0" 20 | cfg-if = "0.1" 21 | log = "0.4" 22 | console_log = { version = "0.2", optional = true, features = ["color"] } 23 | futures-util = "0.3.8" 24 | serde_json = "1.0" 25 | anyhow = "1.0.38" 26 | 27 | # The `console_error_panic_hook` crate provides better debugging of panics by 28 | # logging them with `console.error`. This is great for development, but requires 29 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 30 | # code size when deploying. 31 | console_error_panic_hook = { version = "0.1.6", optional = true } 32 | 33 | # `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size 34 | # compared to the default allocator's ~10K. It is slower than the default 35 | # allocator, however. 36 | # 37 | # Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now. 38 | wee_alloc = { version = "0.4.5", optional = true } 39 | 40 | [dependencies.web-sys] 41 | version = "0.3.4" 42 | features = [ 43 | 'Headers', 44 | 'Request', 45 | 'RequestInit', 46 | 'RequestMode', 47 | 'Response', 48 | 'Window' 49 | ] 50 | 51 | [dev-dependencies] 52 | wasm-bindgen-test = "0.3.13" 53 | 54 | [profile.release] 55 | # Tell `rustc` to optimize for small code size. 56 | opt-level = "s" 57 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 28 | React App 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust 🦀 + Create React App ⚛️ + Typescript 2 | 3 | This is a demo application that fetches generic information about a repository using the Github Graphql API. This repository is meant to serve as a springboard for creating new applications that use React and Rust/WASM. 4 | 5 | A hosted version is [available here](https://aleks.bg/rust-cra). You will need a Github API token with read permissions. Get one [here](https://github.com/settings/tokens). 6 | 7 | The application requests information from the Github Graphql API using the Browser's fetch API through [web-sys](https://crates.io/crates/web-sys) with Rust's [graphql-client](https://crates.io/crates/graphql-client). The results are shown in a simple [React app](https://reactjs.org/docs/create-a-new-react-app.html). 8 | 9 | ## How to get Started 10 | 11 | You will need [`yarn`](https://yarnpkg.com/), [Rust](https://www.rust-lang.org/tools/install) and [`wasm-pack`](https://rustwasm.github.io/wasm-pack/). There are detailed instructions on how to set up your computer for Rust & WASM development in the [Rust & Webassembly book](https://rustwasm.github.io/docs/book/). 12 | 13 | Check out or fork this repository 14 | 15 | ``` 16 | git clone git@github.com:adimit/react-wasm-github-api-demo.git 17 | cd react-wasm-github-api-demo 18 | ``` 19 | 20 | then run install the JS dependencies and run the test server: 21 | 22 | ``` 23 | yarn install 24 | yarn start 25 | ``` 26 | 27 | The application should now be reachable from `http://localhost:3000`. 28 | 29 | Whenever you edit a TypeScript *or* Rust file, or edit your graphql schema, queries or `Cargo.toml`, the server should automatically pick up the changes and reload. 30 | 31 | After changes to the Rust side of things, you may need to reload the page in the browser manually. 32 | 33 | ## Downloading the Graphql Schema 34 | 35 | To re-fetch the Github Graphql schema, use the `yarn` target `download-schema`. Don't forget to set the env variable `GITHUB_API_TOKEN` first. 36 | 37 | ``` 38 | env GITHUB_API_TOKEN="xxx" yarn download-schema 39 | ``` 40 | 41 | # About the Code 42 | 43 | ## Graphql Web Client 44 | 45 | The [Graphql Web Client](https://github.com/graphql-rust/graphql-client/blob/master/graphql_client/src/web.rs) is currently [incompatible](https://github.com/graphql-rust/graphql-client/issues/331) with the stabilized futures API. As a workaround, this repository contains an almost verbatim copy of the graphql web client. 46 | 47 | You can re-use the client in `web.rs` for your own application, or wait until the necessary [PR is merged](https://github.com/graphql-rust/graphql-client/pull/327) upstream. 48 | 49 | ## Plumbing 50 | 51 | The symbolic link `src/pkg` links the Rust WASM JS output into the JS application. To import any function exported in `lib.rs` with the appropriate `wasm_bindgen` signature, just use 52 | 53 | ``` 54 | import { yourFunction } from `./pkg`; 55 | ``` 56 | 57 | Take care to adjust the relative path to `pkg`. This setup is a bit awkward, but allows for seamless recompilation of the application whenever Rust components change. 58 | -------------------------------------------------------------------------------- /backend/src/web.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::anyhow; 4 | use graphql_client::{GraphQLQuery, Response}; 5 | use wasm_bindgen::{JsCast, JsValue}; 6 | use wasm_bindgen_futures::JsFuture; 7 | use web_sys::{Request, RequestInit, RequestMode}; 8 | 9 | pub mod github { 10 | use graphql_client::{GraphQLQuery, Response}; 11 | const GITHUB: &str = "https://api.github.com/graphql"; 12 | 13 | pub struct Github { 14 | token: String, 15 | } 16 | 17 | impl Github { 18 | pub fn new(token: Token) -> Self 19 | where 20 | Token: Into, 21 | { 22 | Github { 23 | token: token.into(), 24 | } 25 | } 26 | 27 | pub async fn graphql( 28 | &self, 29 | query: Q, 30 | variables: Q::Variables, 31 | ) -> anyhow::Result> { 32 | super::gql_call( 33 | query, 34 | variables, 35 | GITHUB.into(), 36 | [ 37 | ("Authorization".into(), format!("Bearer {}", &self.token)), 38 | ("Accept".into(), "application/vnd.github.v3+json".into()), 39 | ("Content-Type".into(), "application/json".into()), 40 | ] 41 | .iter() 42 | .cloned() 43 | .collect(), 44 | ) 45 | .await 46 | } 47 | } 48 | } 49 | 50 | fn js_error(message: String) -> impl Fn(JsValue) -> anyhow::Error { 51 | move |jsvalue| { 52 | anyhow!( 53 | "{}: {}", 54 | message, 55 | js_sys::Error::from(jsvalue) 56 | .message() 57 | .as_string() 58 | .unwrap_or("No idea why.".to_string()) 59 | ) 60 | } 61 | } 62 | 63 | async fn gql_call( 64 | _query: Q, 65 | variables: Q::Variables, 66 | endpoint: String, 67 | headers: HashMap, 68 | ) -> anyhow::Result> { 69 | let body = serde_json::to_string(&Q::build_query(variables))?; 70 | let window = web_sys::window().ok_or(anyhow!("Could not find window"))?; 71 | let mut request_init = RequestInit::new(); 72 | request_init 73 | .headers(&JsValue::from_serde(&headers)?) 74 | .method("POST") 75 | .mode(RequestMode::Cors) 76 | .body(Option::Some(&JsValue::from_str(&body))); 77 | let request = Request::new_with_str_and_init(&endpoint, &request_init) 78 | .map_err(js_error("Could not create request".into()))?; 79 | let response = JsFuture::from(window.fetch_with_request(&request)) 80 | .await 81 | .map_err(js_error("Could not execute request".into()))?; 82 | let text_promise = response 83 | .dyn_into::() 84 | .map_err(js_error("Could not pare response".into())) 85 | .and_then(|cast| { 86 | cast.text() 87 | .map_err(js_error("Could not get text from response".into())) 88 | })?; 89 | 90 | let text = JsFuture::from(text_promise) 91 | .await 92 | .map_err(js_error("Could not resolve text promise".into())) 93 | .and_then(|jsvalue| jsvalue.as_string().ok_or(anyhow!("JsValue not a string")))?; 94 | let serde: serde_json::Value = serde_json::from_str(&text)?; 95 | log::trace!("{:#?}", serde); 96 | if serde["message"].is_string() { 97 | Err(anyhow!("Graphql error: {}", &serde["message"])) 98 | } else { 99 | serde_json::from_value(serde) 100 | .map_err(|err| anyhow!("Could not parse response data. {}", err.to_string())) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use cfg_if::cfg_if; 3 | use graphql_client::GraphQLQuery; 4 | use serde::{Deserialize, Serialize}; 5 | use wasm_bindgen::prelude::*; 6 | 7 | // we import all types from the generated query 8 | use branch_head_commit_author::Variables as QueryVariables; 9 | use branch_head_commit_author::*; 10 | 11 | mod utils; 12 | mod web; 13 | 14 | // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global 15 | // allocator. 16 | #[cfg(feature = "wee_alloc")] 17 | #[global_allocator] 18 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 19 | 20 | // Scalar types declared in the schema need to be declared in the 21 | // scope that declares the graphql types 22 | type DateTime = String; 23 | type URI = String; 24 | type GitObjectID = String; 25 | 26 | #[derive(GraphQLQuery)] 27 | #[graphql(schema_path = "schema.json", query_path = "src/head-query.graphql")] 28 | pub struct BranchHeadCommitAuthor; 29 | 30 | #[derive(Debug, Serialize, Deserialize)] 31 | pub struct User { 32 | avatar_url: URI, 33 | handle: Option, 34 | name: Option, 35 | email: Option, 36 | } 37 | 38 | #[derive(Debug, Serialize, Deserialize)] 39 | pub struct Repo { 40 | name_with_owner: String, 41 | owner: User, 42 | } 43 | 44 | #[derive(Debug, Serialize, Deserialize)] 45 | pub struct Branch { 46 | name: String, 47 | head: Commit, 48 | } 49 | 50 | #[derive(Debug, Serialize, Deserialize)] 51 | pub struct Commit { 52 | author: User, 53 | committer: User, 54 | sha: String, 55 | message: String, 56 | } 57 | 58 | #[derive(Debug, Serialize, Deserialize)] 59 | pub struct GraphqlError { 60 | message: String, 61 | } 62 | 63 | #[derive(Debug, Serialize, Deserialize)] 64 | pub struct RateLimitInfo { 65 | cost: i64, 66 | limit: i64, 67 | node_count: i64, 68 | remaining: i64, 69 | used: i64, 70 | reset_at: String, 71 | } 72 | 73 | #[derive(Debug, Serialize, Deserialize)] 74 | pub struct Data { 75 | errors: Option>, 76 | rate_limit_info: Option, 77 | branch: Option, 78 | repo: Option, 79 | } 80 | 81 | cfg_if! { 82 | if #[cfg(feature = "console_log")] { 83 | fn init_log() { 84 | console_log::init_with_level(log::Level::Trace).expect("error initializing console logging"); 85 | } 86 | } else { 87 | fn init_log() {} 88 | } 89 | } 90 | 91 | #[wasm_bindgen] 92 | pub fn init() { 93 | init_log(); 94 | crate::utils::set_panic_hook(); 95 | } 96 | 97 | impl Into for Data { 98 | fn into(self) -> JsValue { 99 | JsValue::from_serde(&self).unwrap() 100 | } 101 | } 102 | 103 | #[wasm_bindgen] 104 | pub async fn run_graphql( 105 | owner: String, 106 | repo: String, 107 | branch: String, 108 | token: String, 109 | ) -> Result { 110 | run_graphql_private(owner, repo, branch, token) 111 | .await 112 | .map_err(|err| js_sys::Error::new(&err.to_string())) 113 | } 114 | 115 | fn get_rate_limit_info(rate_limit: BranchHeadCommitAuthorRateLimit) -> RateLimitInfo { 116 | RateLimitInfo { 117 | cost: rate_limit.cost, 118 | limit: rate_limit.limit, 119 | node_count: rate_limit.node_count, 120 | remaining: rate_limit.remaining, 121 | used: rate_limit.used, 122 | reset_at: rate_limit.reset_at, 123 | } 124 | } 125 | 126 | async fn run_graphql_private( 127 | owner: String, 128 | repo: String, 129 | branch: String, 130 | token: String, 131 | ) -> anyhow::Result { 132 | let github = web::github::Github::new(token); 133 | let response = github 134 | .graphql( 135 | BranchHeadCommitAuthor, 136 | QueryVariables { 137 | branch: branch.clone(), 138 | owner, 139 | repo_name: repo, 140 | }, 141 | ) 142 | .await?; 143 | 144 | let data = response.data.ok_or(anyhow!("No data on response"))?; 145 | Ok(Data { 146 | rate_limit_info: data.rate_limit.map(get_rate_limit_info), 147 | repo: data.repository.as_ref().map(get_repo_info).transpose()?, 148 | branch: data 149 | .repository 150 | .as_ref() 151 | .and_then(|repo| repo.ref_.as_ref().map(get_branch_info)) 152 | .transpose()?, 153 | errors: response.errors.map(|error_list| { 154 | error_list 155 | .into_iter() 156 | .map(|error| GraphqlError { 157 | message: error.message, 158 | }) 159 | .collect::>() 160 | }), 161 | }) 162 | } 163 | 164 | fn get_branch_info(branch: &BranchHeadCommitAuthorRepositoryRef) -> anyhow::Result { 165 | let head = branch 166 | .target 167 | .as_ref() 168 | .ok_or(anyhow!("No target for branch"))?; 169 | 170 | Ok(Branch { 171 | name: branch.name.to_string(), 172 | head: get_commit_info_from_target(head)?, 173 | }) 174 | } 175 | 176 | fn get_repo_info(repo: &BranchHeadCommitAuthorRepository) -> anyhow::Result { 177 | Ok(Repo { 178 | name_with_owner: repo.name_with_owner.to_string(), 179 | owner: get_user_from_owner(&repo.owner), 180 | }) 181 | } 182 | 183 | fn get_commit_info_from_target( 184 | head: &BranchHeadCommitAuthorRepositoryRefTarget, 185 | ) -> anyhow::Result { 186 | if let BranchHeadCommitAuthorRepositoryRefTargetOn::Commit(commit) = &head.on { 187 | let github_author = commit 188 | .author 189 | .as_ref() 190 | .ok_or(anyhow!("No author on commit {}", commit.oid))?; 191 | 192 | let github_committer = commit 193 | .committer 194 | .as_ref() 195 | .ok_or(anyhow!("No committer on commit {}", commit.oid))?; 196 | 197 | let author = User { 198 | avatar_url: github_author.avatar_url.to_string(), 199 | name: github_author.name.as_ref().map(String::from), 200 | handle: github_author 201 | .user 202 | .as_ref() 203 | .map(|user| user.login.to_string()), 204 | email: github_author.email.as_ref().map(String::from), 205 | }; 206 | 207 | let committer = User { 208 | avatar_url: github_committer.avatar_url.to_string(), 209 | name: github_committer.name.as_ref().map(String::from), 210 | handle: github_committer 211 | .user 212 | .as_ref() 213 | .map(|user| user.login.to_string()), 214 | email: github_committer.email.as_ref().map(String::from), 215 | }; 216 | 217 | Ok(Commit { 218 | author, 219 | committer, 220 | message: commit.message.to_string(), 221 | sha: commit.oid.to_string(), 222 | }) 223 | } else { 224 | Err(anyhow!("ref does not appear to be a commit")) 225 | } 226 | } 227 | 228 | fn get_user_from_owner(owner: &BranchHeadCommitAuthorRepositoryOwner) -> User { 229 | match &owner.on { 230 | BranchHeadCommitAuthorRepositoryOwnerOn::User(user) => User { 231 | avatar_url: owner.avatar_url.to_string(), 232 | name: user.name.as_ref().map(String::from), 233 | email: Option::Some(user.email.to_string()), 234 | handle: Option::Some(owner.login.to_string()), 235 | }, 236 | BranchHeadCommitAuthorRepositoryOwnerOn::Organization(orga) => User { 237 | avatar_url: owner.avatar_url.to_string(), 238 | name: orga.name.as_ref().map(String::from), 239 | handle: Option::Some(owner.login.to_string()), 240 | email: orga.email.as_ref().map(String::from), 241 | }, 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { 3 | Avatar, 4 | Card, 5 | CardContent, 6 | CircularProgress, 7 | Container, 8 | FormLabel, 9 | Grid, 10 | LinearProgress, 11 | List, 12 | ListItem, 13 | ListItemAvatar, 14 | ListItemText, 15 | makeStyles, 16 | Paper, 17 | TextField, 18 | Theme, 19 | Typography, 20 | } from "@material-ui/core"; 21 | import { run_graphql } from "./pkg"; 22 | 23 | const InputField = ({ 24 | setValue, 25 | ...props 26 | }: { 27 | id: string; 28 | label: string; 29 | setValue: (val: string) => void; 30 | defaultValue?: string; 31 | }) => ( 32 | { 34 | if (e.key === "Enter") { 35 | setValue((e.target as any).value); 36 | } 37 | }} 38 | onBlur={(e) => setValue(e.target.value)} 39 | {...props} 40 | /> 41 | ); 42 | 43 | interface User { 44 | avatar_url: string; 45 | email?: string; 46 | handle?: string; 47 | name?: string; 48 | } 49 | 50 | interface Branch { 51 | name: string; 52 | head: { 53 | author: User; 54 | message: string; 55 | committer: User; 56 | sha: string; 57 | }; 58 | } 59 | 60 | interface RateLimitInfo { 61 | cost: number; 62 | limit: number; 63 | node_count: number; 64 | remaining: number; 65 | reset_at: string; 66 | used: number; 67 | } 68 | 69 | interface Repo { 70 | name_with_owner: string; 71 | owner: User; 72 | } 73 | 74 | interface BackendData { 75 | branch?: Branch; 76 | rate_limit_info?: RateLimitInfo; 77 | errors?: [{ message: string }]; 78 | repo?: Repo; 79 | } 80 | 81 | const RateLimitInfoDisplay: React.FC = ({ 82 | used, 83 | cost, 84 | limit, 85 | node_count, 86 | reset_at, 87 | }) => { 88 | const diff = Math.floor( 89 | (new Date(reset_at).getTime() - new Date().getTime()) / 1000 / 60 90 | ); 91 | const classes = useStyles(); 92 | return ( 93 | 94 | 95 | 96 | Rate Limit 97 | 98 | {diff >= 0 ? `Resets in ${diff} minutes` : "Already reset."} 99 | 100 | {`Usage: ${used}/${limit}`} 101 | Last Request 102 | {`Cost: ${cost}, nodes: ${node_count}`} 103 | 104 | 105 | ); 106 | }; 107 | 108 | const RepositoryInfo: React.FC = ({ name_with_owner, owner }) => { 109 | const classes = useStyles(); 110 | return ( 111 | 112 | 113 | Repository 114 | {name_with_owner} 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ); 126 | }; 127 | 128 | const BranchInfo: React.FC = ({ 129 | name, 130 | head: { sha, message, author, committer }, 131 | }) => { 132 | const classes = useStyles(); 133 | return ( 134 | 135 | 136 | Branch 137 | {name} 138 | Head 139 | {sha} 140 | {message} 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | ); 158 | }; 159 | 160 | const RenderData: React.FC = ({ 161 | repo, 162 | rate_limit_info, 163 | branch, 164 | }) => ( 165 | <> 166 | {branch && } 167 | {repo && } 168 | {rate_limit_info && } 169 | {repo && !branch && ( 170 | Could not find this branch 171 | )} 172 | 173 | ); 174 | 175 | const RenderError: React.FC<{ 176 | oops?: string; 177 | errors?: [{ message: string }]; 178 | }> = ({ oops, errors }) => ( 179 | <> 180 | {oops && {oops}} 181 | {errors && 182 | errors.map(({ message }) => ( 183 | {message} 184 | ))} 185 | 186 | ); 187 | 188 | const RenderResult: React.FC = ({ 189 | error, 190 | ...props 191 | }) => ( 192 | <> 193 | 194 | 195 | 196 | ); 197 | 198 | const useStyles = makeStyles((theme: Theme) => ({ 199 | container: { 200 | marginTop: theme.spacing(4), 201 | }, 202 | dataCard: { 203 | margin: theme.spacing(2), 204 | }, 205 | })); 206 | 207 | function App() { 208 | const [{ repo, owner, branch }, setRepositoryInfo] = React.useState<{ 209 | owner: string; 210 | branch: string; 211 | repo: string; 212 | }>({ owner: "", branch: "", repo: "" }); 213 | const [apiKey, setApiKey] = React.useState( 214 | localStorage.getItem("github.token") ?? "" 215 | ); 216 | 217 | const [{ loading, data, error }, setFetchResult] = React.useState<{ 218 | loading: boolean; 219 | data?: BackendData; 220 | error?: string; 221 | }>({ loading: false }); 222 | useEffect(() => { 223 | if (repo !== "" && branch !== "" && owner !== "" && apiKey !== "") { 224 | run_graphql(owner, repo, branch, apiKey).then( 225 | (result: BackendData) => { 226 | setFetchResult({ loading: false, data: result }); 227 | }, 228 | (error: Error) => { 229 | setFetchResult({ 230 | loading: false, 231 | error: `${error.name}: ${error.message}`, 232 | }); 233 | } 234 | ); 235 | setFetchResult({ loading: true }); 236 | } 237 | }, [repo, owner, branch, apiKey]); 238 | 239 | useEffect(() => { 240 | apiKey !== "" && localStorage.setItem("github.token", apiKey); 241 | }, [apiKey]); 242 | 243 | const classes = useStyles(); 244 | 245 | return ( 246 | 247 | 248 | 249 | 250 | 254 | setRepositoryInfo({ branch, owner: val, repo }) 255 | } 256 | /> 257 | 261 | setRepositoryInfo({ repo: val, owner, branch }) 262 | } 263 | /> 264 | 268 | setRepositoryInfo({ repo, owner, branch: val }) 269 | } 270 | /> 271 | setApiKey(val)} 275 | defaultValue={apiKey} 276 | /> 277 | 278 | 279 | 280 | 281 | 282 | 283 | {loading ? ( 284 | 285 | ) : ( 286 | 287 | )} 288 | 289 | 290 | 291 | ); 292 | } 293 | 294 | export default App; 295 | --------------------------------------------------------------------------------