├── 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 |
--------------------------------------------------------------------------------