Skip to content

Commit 3b9947c

Browse files
committedDec 26, 2022
petshop-rs impl
0 parents  commit 3b9947c

30 files changed

+8108
-0
lines changed
 

‎README.md

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# grpc-petshop-rs
2+
3+
**Showcasing minimalistic full-stack project using gRPC** with a Rust backend and a Browser frontend.
4+
5+
More specific:
6+
7+
- **Rust** using `tokio`, `tonic`
8+
- **Browser** using `solid-js`
9+
10+
<img src="assets/rust-logo.png" width="100" />
11+
<img src="assets/grpc-logo.png" width="100" />
12+
<img src="assets/solidjs-logo.png" width="100" />
13+
14+
## Installation
15+
16+
- [server README](./server/README.md)
17+
- [client README](./client/README.md)
18+
19+
## About the stack
20+
21+
Technologies in the stack are targeting:
22+
23+
- **fast and simple development cycle**
24+
- client (hot-)reloading: ~1s
25+
- website
26+
- API contract changes
27+
- server auto-compiling and restarting: ~3s
28+
- implementation
29+
- API contract changes
30+
- **contract-first** API approach
31+
- **resource-saving** in any way
32+
- server
33+
- binary ~4MB
34+
- memory ~3MB
35+
- high-performance native application
36+
- client
37+
- packaged ~90kB
38+
- high-performance reactive framework
39+
40+
## Support matrix
41+
42+
- primarily tested on MacOS, the setup should also work for Linux
43+
- as the setup is using a bash script it will not run on Windows natively, but maybe on WSL
44+
45+
| Support | Client | Server |
46+
|----|----|----|
47+
|Linux|yes|yes|
48+
|MacOS|yes|yes|
49+
|Windows|unknown|yes|

‎assets/grpc-logo.png

46.3 KB
Loading

‎assets/rust-logo.png

74.2 KB
Loading

‎assets/solidjs-logo.png

29.1 KB
Loading

‎client/.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/node_modules
2+
/dist
3+
4+
/src/generated/proto/*
5+
!/src/generated/proto/.gitkeep

‎client/.prettierignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/dist
2+
/node_modules
3+
/src/generated
4+
/package.json
5+
/package-lock.json

‎client/README.md

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# grpc-petshop-rs client
2+
3+
## Installation
4+
5+
- install `protoc`: https://grpc.io/docs/protoc-installation/
6+
- install node_modules: `npm ci`
7+
8+
## Development
9+
10+
```bash
11+
# start development
12+
npm start
13+
```
14+
15+
will automatically:
16+
17+
- hotload website via `vite`
18+
- generate and watch/update gRPC resources
19+
20+
## Usage
21+
22+
- make sure server is started
23+
- browser `http://localhost:3000`
24+
- Login
25+
- email: `user@email.com`
26+
- password: `password`
27+
28+
## Before commit
29+
30+
- `npm run pretty`

‎client/ecosystem.config.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module.exports = {
2+
apps: [
3+
{
4+
script: 'npm run dev',
5+
},
6+
{
7+
script: './scripts/protogen.sh',
8+
watch: '../server/proto',
9+
},
10+
],
11+
}

‎client/index.html

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="theme-color" content="#000000" />
7+
<title>grpc-petshop-rs</title>
8+
<link
9+
rel="stylesheet"
10+
href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css"
11+
/>
12+
</head>
13+
<body>
14+
<noscript>You need to enable JavaScript to run this app.</noscript>
15+
<div id="root"></div>
16+
17+
<script src="/src/index.tsx" type="module"></script>
18+
</body>
19+
</html>

‎client/package-lock.json

+6,345
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎client/package.json

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "client",
3+
"version": "1.0.0",
4+
"license": "MIT",
5+
"private": true,
6+
"scripts": {
7+
"start": "pm2 start ecosystem.config.js --no-daemon",
8+
"dev": "vite",
9+
"build": "vite build",
10+
"serve": "vite preview",
11+
"pretty": "prettier --write ."
12+
},
13+
"devDependencies": {
14+
"pm2": "^5.2.2",
15+
"prettier": "^2.8.1",
16+
"ts-proto": "^1.136.1",
17+
"ts-protoc-gen": "^0.15.0",
18+
"vite": "^4.0.1",
19+
"vite-plugin-solid": "^2.5.0"
20+
},
21+
"dependencies": {
22+
"@improbable-eng/grpc-web": "^0.15.0",
23+
"google-protobuf": "^3.21.2",
24+
"solid-js": "^1.6.5"
25+
},
26+
"prettier": {
27+
"semi": false,
28+
"singleQuote": true,
29+
"trailingComma": "es5",
30+
"tabWidth": 2
31+
}
32+
}

‎client/scripts/protogen.sh

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
3+
mkdir -p ./src/generated/proto
4+
5+
PROTOC=`command -v protoc`
6+
if [[ "$PROTOC" == "" ]]; then
7+
echo "Required "protoc" to be installed. Please visit https://github.com/protocolbuffers/protobuf/releases."
8+
exit -1
9+
fi
10+
11+
echo "Compiling protobuf definitions"
12+
protoc \
13+
--plugin=./node_modules/.bin/protoc-gen-ts_proto \
14+
--ts_proto_out=./src/generated/proto \
15+
--ts_proto_opt=esModuleInterop=true \
16+
--ts_proto_opt=outputClientImpl=grpc-web \
17+
-I ../server/proto \
18+
../server/proto/auth.v1.proto \
19+
../server/proto/shop.v1.proto
20+
21+
# do not terminate
22+
while true; do sleep 86400; done

‎client/src/App.tsx

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Show } from 'solid-js'
2+
import { authApi, setAuthApi, setShopApi, shopApi } from './signals'
3+
import Login from './Login'
4+
import PetList from './PetList'
5+
import { AuthV1Api } from './api'
6+
7+
export default function App() {
8+
function logout() {
9+
authApi()
10+
.logout()
11+
.then(() => {
12+
setAuthApi(new AuthV1Api())
13+
setShopApi()
14+
})
15+
}
16+
17+
return (
18+
<div class="container">
19+
<nav>
20+
<ul>
21+
<li>
22+
<strong>Petshop</strong>
23+
</li>
24+
</ul>
25+
<ul>
26+
<Show when={shopApi()}>
27+
<li>
28+
<a href="#" onclick={logout}>
29+
Logout
30+
</a>
31+
</li>
32+
</Show>
33+
</ul>
34+
</nav>
35+
<Show when={shopApi()} fallback={<Login />}>
36+
<PetList />
37+
</Show>
38+
</div>
39+
)
40+
}

‎client/src/Login.tsx

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { AuthV1Api, ShopV1Api } from './api'
2+
import { authApi, setAuthApi, setShopApi } from './signals'
3+
4+
export default function Login() {
5+
let email: HTMLInputElement | undefined
6+
let password: HTMLInputElement | undefined
7+
8+
function handleSubmit(event: Event) {
9+
event.preventDefault()
10+
11+
authApi()
12+
.login(email?.value ?? '', password?.value ?? '')
13+
.then((reply) => {
14+
setAuthApi(new AuthV1Api(reply.usertoken))
15+
setShopApi(new ShopV1Api(reply.usertoken))
16+
})
17+
.catch((e) => {
18+
alert('login failed')
19+
})
20+
}
21+
22+
return (
23+
<div>
24+
<h2>Login</h2>
25+
<form onSubmit={handleSubmit}>
26+
<label for="email">
27+
Email
28+
<input ref={email} name="email" />
29+
</label>
30+
<label for="password">
31+
Password
32+
<input ref={password} name="password" type="password" />
33+
</label>
34+
<button type="submit">Login</button>
35+
</form>
36+
</div>
37+
)
38+
}

‎client/src/PetList.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createResource, For, Show } from 'solid-js'
2+
import { Pet } from './api'
3+
import { shopApi } from './signals'
4+
5+
export default function PetList() {
6+
const [pets, { refetch }] = createResource<Pet[] | undefined>(
7+
async (k, info) => {
8+
return await shopApi()?.getPets()
9+
}
10+
)
11+
12+
function buyPet(id: number) {
13+
shopApi()
14+
?.buyPet(id)
15+
.then(() => {
16+
refetch()
17+
})
18+
.catch((e) => {
19+
alert('buy pet failed')
20+
})
21+
}
22+
23+
return (
24+
<div>
25+
<h3>PetList</h3>
26+
<Show when={pets()} fallback={<p>fetching pets</p>} keyed>
27+
{(p) => (
28+
<For each={p} fallback={<p>all pets are sold</p>}>
29+
{(item) => {
30+
return (
31+
<article>
32+
<h4>{item.name}</h4>
33+
<p>{item.age}yrs</p>
34+
<button onClick={() => buyPet(item.id)}>Buy</button>
35+
</article>
36+
)
37+
}}
38+
</For>
39+
)}
40+
</Show>
41+
</div>
42+
)
43+
}

‎client/src/api.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { grpc } from '@improbable-eng/grpc-web'
2+
import {
3+
AuthenticationClientImpl,
4+
GrpcWebError,
5+
GrpcWebImpl,
6+
} from './generated/proto/auth.v1'
7+
import { Pet, PetShopClientImpl } from './generated/proto/shop.v1'
8+
9+
export { Pet } from './generated/proto/shop.v1'
10+
11+
const HOST = 'http://localhost:8081'
12+
13+
export class AuthV1Api {
14+
private rpc: GrpcWebImpl
15+
private client: AuthenticationClientImpl
16+
17+
constructor(usertoken?: string) {
18+
let metadata: grpc.Metadata | undefined = undefined
19+
if (usertoken !== undefined) {
20+
metadata = new grpc.Metadata()
21+
metadata.append('authentication', `Bearer ${usertoken}`)
22+
}
23+
24+
this.rpc = new GrpcWebImpl(HOST, { metadata })
25+
this.client = new AuthenticationClientImpl(this.rpc)
26+
}
27+
28+
async login(email: string, password: string) {
29+
const { usertoken } = await this.client.Login({ email, password })
30+
return { usertoken }
31+
}
32+
33+
async logout() {
34+
await this.client.Logout({})
35+
}
36+
}
37+
38+
export class ShopV1Api {
39+
private rpc: GrpcWebImpl
40+
private client: PetShopClientImpl
41+
42+
constructor(usertoken: string) {
43+
const metadata = new grpc.Metadata()
44+
metadata.append('authentication', `Bearer ${usertoken}`)
45+
46+
this.rpc = new GrpcWebImpl(HOST, { metadata })
47+
this.client = new PetShopClientImpl(this.rpc)
48+
}
49+
50+
async getPets(): Promise<Pet[]> {
51+
const { pets } = await this.client.GetPets({})
52+
return pets
53+
}
54+
55+
async buyPet(id: number) {
56+
await this.client.BuyPet({ id })
57+
}
58+
}

‎client/src/generated/proto/.gitkeep

Whitespace-only changes.

‎client/src/index.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { render } from 'solid-js/web'
2+
import App from './App'
3+
4+
render(App, document.getElementById('root')!)

‎client/src/signals.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createSignal } from 'solid-js'
2+
import { AuthV1Api, ShopV1Api } from './api'
3+
4+
export const [authApi, setAuthApi] = createSignal(new AuthV1Api())
5+
export const [shopApi, setShopApi] = createSignal<ShopV1Api>()

‎client/tsconfig.json

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"strict": true,
4+
"target": "ESNext",
5+
"module": "CommonJS",
6+
"moduleResolution": "node",
7+
"allowSyntheticDefaultImports": true,
8+
"esModuleInterop": true,
9+
"jsx": "preserve",
10+
"jsxImportSource": "solid-js",
11+
"types": ["vite/client"],
12+
"noEmit": true,
13+
"isolatedModules": true
14+
}
15+
}

‎client/vite.config.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from 'vite'
2+
import solidPlugin from 'vite-plugin-solid'
3+
4+
export default defineConfig({
5+
plugins: [solidPlugin()],
6+
server: {
7+
port: 3000,
8+
},
9+
build: {
10+
target: 'esnext',
11+
},
12+
})

‎server/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/target

‎server/Cargo.lock

+1,089
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎server/Cargo.toml

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "server"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]
9+
async-trait = "0.1.60"
10+
prost = "0.11.3"
11+
tokio = { version = "1.23.0", features = ["full"] }
12+
tonic = "0.8.3"
13+
tonic-web = "0.5.0"
14+
tower-http = { version = "0.3.5", features = ["cors"] }
15+
16+
[build-dependencies]
17+
tonic-build = "0.8.4"

‎server/README.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# grpc-petshop-rs server
2+
3+
## Installation
4+
5+
- cargo-watch: `cargo install cargo-watch`
6+
7+
## Development
8+
9+
```bash
10+
# start development
11+
cargo watch -x run
12+
```
13+
14+
will automatically:
15+
16+
- recompile and restart server
17+
- generate and watch/update gRPC resources
18+
19+
## Before commit
20+
21+
- `cargo fmt`

‎server/build.rs

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
fn main() {
2+
tonic_build::configure()
3+
.compile(&["proto/auth.v1.proto", "proto/shop.v1.proto"], &["proto"])
4+
.expect("compile gRPC proto files.");
5+
}

‎server/proto/auth.v1.proto

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
syntax = "proto3";
2+
3+
package auth.v1;
4+
5+
service Authentication {
6+
rpc Login (LoginRequest) returns (LoginReply);
7+
rpc Logout (LogoutRequest) returns (LogoutReply);
8+
}
9+
10+
message LoginRequest {
11+
string email = 1;
12+
string password = 2;
13+
}
14+
message LoginReply {
15+
string usertoken = 1;
16+
}
17+
18+
message LogoutRequest {}
19+
message LogoutReply {}

‎server/proto/shop.v1.proto

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
syntax = "proto3";
2+
3+
package shop.v1;
4+
5+
service PetShop {
6+
rpc GetPets (GetPetsRequest) returns (GetPetsReply);
7+
rpc BuyPet (BuyPetRequest) returns (BuyPetReply);
8+
}
9+
10+
message GetPetsRequest {}
11+
message GetPetsReply {
12+
repeated Pet pets = 1;
13+
}
14+
15+
message Pet {
16+
int64 id = 1;
17+
int32 age = 2;
18+
string name = 3;
19+
}
20+
21+
message BuyPetRequest {
22+
int64 id = 1;
23+
}
24+
message BuyPetReply {}

‎server/src/api.rs

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
use self::grpc::{
2+
authentication_server::{Authentication, AuthenticationServer},
3+
pet_shop_server::{PetShop, PetShopServer},
4+
BuyPetReply, BuyPetRequest, GetPetsReply, GetPetsRequest, LoginReply, LoginRequest,
5+
LogoutReply, LogoutRequest, Pet,
6+
};
7+
use crate::PetDb;
8+
use async_trait::async_trait;
9+
use tonic::Result;
10+
use tonic::{Request, Response, Status};
11+
12+
/// contains generated gRPC code
13+
mod grpc {
14+
tonic::include_proto!("auth.v1");
15+
tonic::include_proto!("shop.v1");
16+
}
17+
18+
/// creates auth server
19+
pub fn auth() -> AuthenticationServer<AuthenticationService> {
20+
AuthenticationServer::new(AuthenticationService)
21+
}
22+
23+
/// creates shop server
24+
pub fn shop(db: PetDb) -> PetShopServer<ShopService> {
25+
PetShopServer::new(ShopService { db })
26+
}
27+
28+
/// top secret user token returned, after login
29+
const TOKEN: &'static str = "secrettoken";
30+
31+
/// meta key used for auth
32+
const AUTH_META: &'static str = "authentication";
33+
34+
#[derive(Clone)]
35+
pub struct AuthenticationService;
36+
37+
#[async_trait]
38+
impl Authentication for AuthenticationService {
39+
async fn login(&self, request: Request<LoginRequest>) -> Result<Response<LoginReply>> {
40+
let request = request.into_inner();
41+
if request.email == "user@email.com" && request.password == "password" {
42+
Ok(Response::new(LoginReply {
43+
usertoken: TOKEN.to_owned(),
44+
}))
45+
} else {
46+
Err(Status::unauthenticated("invalid credentials"))
47+
}
48+
}
49+
50+
async fn logout(&self, request: Request<LogoutRequest>) -> Result<Response<LogoutReply>> {
51+
check_auth_meta(&request)?;
52+
53+
Ok(Response::new(LogoutReply {}))
54+
}
55+
}
56+
57+
#[derive(Clone)]
58+
pub struct ShopService {
59+
db: PetDb,
60+
}
61+
62+
#[async_trait]
63+
impl PetShop for ShopService {
64+
async fn get_pets(&self, request: Request<GetPetsRequest>) -> Result<Response<GetPetsReply>> {
65+
check_auth_meta(&request)?;
66+
67+
let data = self.db.data.lock().await;
68+
Ok(Response::new(GetPetsReply {
69+
pets: data
70+
.iter()
71+
.map(|pet| Pet {
72+
id: pet.id,
73+
age: pet.age,
74+
name: pet.name.clone(),
75+
})
76+
.collect(),
77+
}))
78+
}
79+
80+
async fn buy_pet(&self, request: Request<BuyPetRequest>) -> Result<Response<BuyPetReply>> {
81+
check_auth_meta(&request)?;
82+
83+
let mut data = self.db.data.lock().await;
84+
data.retain(|pet| pet.id != request.get_ref().id);
85+
86+
Ok(Response::new(BuyPetReply {}))
87+
}
88+
}
89+
90+
/// checks whether request has correct auth meta set
91+
fn check_auth_meta<T>(request: &Request<T>) -> Result<()> {
92+
let meta = request.metadata();
93+
if let Some(authentication) = meta.get(AUTH_META) {
94+
if authentication == format!("Bearer {TOKEN}").as_str() {
95+
Ok(())
96+
} else {
97+
Err(Status::unauthenticated("bad authorization token"))
98+
}
99+
} else {
100+
Err(Status::unauthenticated("not authorization meta given"))
101+
}
102+
}

‎server/src/main.rs

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
use std::sync::Arc;
2+
3+
use std::error::Error;
4+
use tokio::{sync::Mutex, try_join};
5+
use tonic::{codegen::http::header::HeaderName, transport::Server};
6+
use tonic_web::GrpcWebLayer;
7+
use tower_http::cors::{Any, CorsLayer};
8+
9+
mod api;
10+
11+
type Result<T> = std::result::Result<T, Box<dyn Error>>;
12+
13+
/// main entry
14+
#[tokio::main]
15+
async fn main() -> Result<()> {
16+
run().await
17+
}
18+
19+
/// macro free entry-point running the server
20+
async fn run() -> Result<()> {
21+
let db = create_pet_db();
22+
23+
// gRPC server on `:8080`
24+
let grpc_server = Server::builder()
25+
.add_service(api::auth())
26+
.add_service(api::shop(db.clone()))
27+
.serve(
28+
"127.0.0.1:8080"
29+
.parse()
30+
.expect("valid address can be parsed"),
31+
);
32+
33+
// http-gRPC bridge server on `:8081`.
34+
// Browser cannot use real gRPC because it's not based on HTTP.
35+
let web_server = Server::builder()
36+
.accept_http1(true)
37+
// because client and server doesn't have the same origin (different port):
38+
// 1. server has to allow origins, headers and methods explicitely
39+
// 2. server has to allow the client to read specific gRPC response headers.
40+
.layer(
41+
CorsLayer::new()
42+
.allow_origin(Any)
43+
.allow_headers(Any)
44+
.allow_methods(Any)
45+
.expose_headers([
46+
HeaderName::from_static("grpc-status"),
47+
HeaderName::from_static("grpc-message"),
48+
]),
49+
)
50+
.layer(GrpcWebLayer::new())
51+
.add_service(api::auth())
52+
.add_service(api::shop(db))
53+
.serve(
54+
"127.0.0.1:8081"
55+
.parse()
56+
.expect("valid address can be parsed"),
57+
);
58+
59+
// run both servers
60+
try_join!(grpc_server, web_server)?;
61+
Ok(())
62+
}
63+
64+
/// fake db with some samples
65+
fn create_pet_db() -> PetDb {
66+
PetDb {
67+
data: Arc::new(Mutex::new(vec![
68+
Pet {
69+
id: 1,
70+
age: 4,
71+
name: "Ferris".to_owned(),
72+
},
73+
Pet {
74+
id: 2,
75+
age: 3,
76+
name: "Lilly".to_owned(),
77+
},
78+
Pet {
79+
id: 3,
80+
age: 8,
81+
name: "Ratty".to_owned(),
82+
},
83+
])),
84+
}
85+
}
86+
87+
#[derive(Clone)]
88+
pub struct PetDb {
89+
data: Arc<Mutex<Vec<Pet>>>,
90+
}
91+
92+
#[derive(Clone)]
93+
struct Pet {
94+
id: i64,
95+
age: i32,
96+
name: String,
97+
}

0 commit comments

Comments
 (0)
Please sign in to comment.