├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── README.md ├── aggregate-pattern ├── .gitignore ├── README.md ├── aggregates-service │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── config.rs │ │ ├── handlers.rs │ │ ├── lib.rs │ │ ├── models.rs │ │ ├── request_builder.rs │ │ └── response_parser.rs ├── customers-service │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── handlers.ts │ │ ├── index.ts │ │ └── models.ts │ ├── tsconfig.json │ └── webpack.config.js ├── incidents-service │ ├── .gitignore │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── pkg │ │ ├── api │ │ ├── api.go │ │ └── handlers.go │ │ └── types │ │ └── types.go ├── migrations.sql └── spin.toml ├── api-with-cronjob ├── .gitignore ├── README.md ├── api │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ └── models.rs ├── cronjob │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── bindings.rs │ │ └── main.rs ├── k8s │ ├── deploy.sh │ ├── k8s.rtc.tmpl │ ├── spin-app.tmpl │ └── spin-cron.tmpl ├── local.rtc.toml ├── spin-cron.toml └── spin.toml ├── application-variable-providers ├── README.md ├── azure-key-vault-provider │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ ├── runtime_config.toml │ ├── spin.toml │ └── src │ │ └── lib.rs └── vault-provider │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ ├── runtime_config.toml │ ├── spin.toml │ └── src │ └── lib.rs ├── caching-rust ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── local.toml ├── migrations.sql ├── spin.toml └── src │ ├── cache.rs │ ├── db.rs │ ├── lib.rs │ └── model.rs ├── content-negotiation-rust ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── spin.toml └── src │ ├── content_negotiation.rs │ ├── lib.rs │ ├── models.rs │ └── service.rs ├── cors-rust ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── migrations.sql ├── spin.toml └── src │ ├── lib.rs │ └── models.rs ├── cqrs-go ├── .gitignore ├── README.md ├── go.mod ├── go.sum ├── main.go ├── migrations.sql ├── pkg │ ├── api │ │ ├── api.go │ │ └── handlers.go │ ├── commands │ │ └── commands.go │ └── queries │ │ └── queries.go └── spin.toml ├── cqrs-rust ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── crates │ ├── commands │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── lib.rs │ │ │ └── models.rs │ └── queries │ │ ├── Cargo.toml │ │ └── src │ │ ├── lib.rs │ │ └── models.rs ├── migrations.sql ├── spin.toml └── src │ └── lib.rs ├── cqrs-servicechaining ├── .gitignore ├── README.md ├── commands │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ ├── models.rs │ │ └── persistence.rs ├── gateway │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── migrations.sql ├── queries │ ├── .gitignore │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── pkg │ │ ├── models │ │ └── models.go │ │ └── persistence │ │ └── persistence.go └── spin.toml ├── distributed-todo-app ├── .gitignore ├── README.md ├── distribute-apps.sh ├── kubernetes │ ├── api.yaml │ ├── db.yaml │ ├── migrations.yaml │ └── stats-generator.yaml ├── run-cron-local.sh ├── run-local.sh └── src │ ├── http-api │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── spin.toml │ └── src │ │ ├── handlers.rs │ │ ├── lib.rs │ │ └── models.rs │ ├── migrations │ ├── .DS_Store │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── scripts │ │ ├── 01-init.sql │ │ └── 02-sample-data.sql │ ├── spin.toml │ └── src │ │ └── main.rs │ └── stats-generator │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── spin.toml │ └── src │ └── main.rs ├── distributed-tracing ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── README.md ├── spin.toml └── src │ └── lib.rs ├── http-crud-go-sqlite ├── .gitignore ├── README.md ├── go.mod ├── go.sum ├── main.go ├── migrations.sql ├── pkg │ ├── api │ │ ├── api.go │ │ └── handlers.go │ ├── persistence │ │ └── sqlite.go │ └── types │ │ └── types.go └── spin.toml ├── http-crud-js-pg ├── .gitignore ├── Makefile ├── README.md ├── package-lock.json ├── package.json ├── scripts │ └── pg │ │ ├── init.sh │ │ └── init.sql ├── spin.toml ├── src │ ├── config.js │ ├── handlers.js │ └── index.js └── webpack.config.js ├── http-crud-js-sqlite ├── .gitignore ├── README.md ├── migrations.sql ├── package-lock.json ├── package.json ├── spin.toml ├── src │ ├── handlers.js │ └── index.js └── webpack.config.js ├── http-crud-rust-mysql ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── Makefile ├── README.md ├── scripts │ └── mysql │ │ ├── entrypoint.sh │ │ └── init.sql ├── spin.toml └── src │ ├── api.rs │ ├── handlers.rs │ ├── lib.rs │ ├── models.rs │ └── persistence.rs ├── image-transformation ├── .gitignore ├── README.md ├── api │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── helpers.rs │ │ └── lib.rs ├── frontend │ ├── app.js │ └── index.html ├── screenshot.png └── spin.toml ├── load-testing-spin-with-k6 ├── README.md ├── breakpoint-test.js ├── scenario │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── spin.toml │ └── src │ │ └── lib.rs ├── smoke-test.js └── stress-test.js ├── long-running-jobs-over-http ├── .gitignore ├── Makefile ├── README.md ├── api │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── local.toml │ ├── migrations.sql │ ├── spin.toml │ └── src │ │ ├── config.rs │ │ ├── handlers.rs │ │ ├── lib.rs │ │ ├── models.rs │ │ └── service.rs ├── data │ ├── .gitignore │ └── .gitkeep ├── mosquitto │ └── mosquitto.conf ├── native-worker │ ├── go.mod │ ├── go.sum │ └── main.go ├── shared │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── spin-worker │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── local.toml │ ├── spin.toml │ └── src │ ├── lib.rs │ └── status_reporter.rs ├── pub-sub-polyglot ├── .gitignore ├── Makefile ├── README.md ├── http-publisher-js │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ ├── spin.toml │ ├── src │ │ └── index.js │ └── webpack.config.js ├── mass-publisher │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── subscriber-go │ ├── .gitignore │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── spin.toml └── subscriber-rust │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── spin.toml │ └── src │ └── lib.rs └── signed-webhooks ├── Makefile ├── README.md ├── hmac ├── .gitignore ├── .vscode │ └── settings.json ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── src │ ├── bindings.rs │ └── lib.rs └── wit │ └── world.wit ├── webhook-consumer ├── .gitignore ├── Makefile ├── Pipfile ├── app.py ├── requirements.txt ├── spin.toml └── wit │ ├── deps │ └── hmac │ │ └── world.wit │ └── world.wit └── webhook-producer ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── migrations.sql ├── spin.toml ├── src ├── bindings.rs ├── lib.rs └── registrations.rs └── wit └── world.wit /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /aggregate-pattern/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aggregates-service" 3 | authors = ["Fermyon Engineering "] 4 | description = "Aggregates Service" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | chrono = { version = "0.4.37", features = ["serde"] } 14 | futures = "0.3.30" 15 | serde = { version = "1.0.197", features = ["derive"] } 16 | serde_json = "1.0.115" 17 | spin-sdk = "3.1.1" 18 | 19 | [workspace] 20 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/src/config.rs: -------------------------------------------------------------------------------- 1 | pub(crate) struct Config { 2 | pub customer_count_uri: String, 3 | pub incidents_grouped_by_customer_uri: String, 4 | pub top_customers_uri: String, 5 | } 6 | 7 | impl Config { 8 | pub fn load() -> anyhow::Result { 9 | Ok(Config { 10 | customer_count_uri: String::from( 11 | "http://customers-service.spin.internal/customers/count", 12 | ), 13 | incidents_grouped_by_customer_uri: String::from( 14 | "http://incidents-service.spin.internal/incidents/grouped-by-customer", 15 | ), 16 | top_customers_uri: String::from( 17 | "http://customers-service.spin.internal/customers/top/5", 18 | ), 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/src/handlers.rs: -------------------------------------------------------------------------------- 1 | use futures::try_join; 2 | use spin_sdk::http::{send, IntoResponse, Params, Request, Response}; 3 | 4 | use crate::{config::Config, models::DashboardModel, request_builder, response_parser}; 5 | 6 | pub async fn get_dashboard(_: Request, _: Params) -> anyhow::Result { 7 | let cfg = Config::load()?; 8 | let req_customer_count = request_builder::customer_count(&cfg); 9 | let req_top_customers = request_builder::top_customers(&cfg); 10 | let req_incidents_grouped_by_customer = request_builder::incidents_grouped_by_customer(&cfg); 11 | 12 | let customer_count_future = send::<_, Response>(req_customer_count); 13 | let top_customers_future = send::<_, Response>(req_top_customers); 14 | let incidents_grouped_by_customer_future = 15 | send::<_, Response>(req_incidents_grouped_by_customer); 16 | 17 | // refactor to let-else 18 | match try_join!( 19 | customer_count_future, 20 | top_customers_future, 21 | incidents_grouped_by_customer_future 22 | ) { 23 | Ok(results) => { 24 | let customer_count = response_parser::customer_count(results.0.body())?; 25 | let top_customers = response_parser::top_customers(results.1.body())?; 26 | let incidents_grouped_by_customer = 27 | response_parser::incidents_grouped_by_customer(results.2.body())?; 28 | 29 | Ok(Response::builder() 30 | .status(200) 31 | .header("Content-Type", "application/json") 32 | .body(DashboardModel::from( 33 | customer_count, 34 | top_customers, 35 | incidents_grouped_by_customer, 36 | )) 37 | .build()) 38 | } 39 | Err(e) => Ok(Response::new(500, e.to_string())), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/src/lib.rs: -------------------------------------------------------------------------------- 1 | use spin_sdk::http::{IntoResponse, Request, Router}; 2 | use spin_sdk::http_component; 3 | 4 | mod config; 5 | mod handlers; 6 | mod models; 7 | mod request_builder; 8 | mod response_parser; 9 | 10 | #[http_component] 11 | fn handle_aggregate(req: Request) -> anyhow::Result { 12 | let mut router = Router::default(); 13 | router.get_async("/aggregates/dashboard", handlers::get_dashboard); 14 | Ok(router.handle(req)) 15 | } 16 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/src/request_builder.rs: -------------------------------------------------------------------------------- 1 | use spin_sdk::http::{Method, Request, RequestBuilder}; 2 | 3 | use crate::config::Config; 4 | 5 | pub fn customer_count(cfg: &Config) -> Request { 6 | build_request(&cfg.customer_count_uri) 7 | } 8 | 9 | pub fn incidents_grouped_by_customer(cfg: &Config) -> Request { 10 | build_request(&cfg.incidents_grouped_by_customer_uri) 11 | } 12 | 13 | pub fn top_customers(cfg: &Config) -> Request { 14 | build_request(&cfg.top_customers_uri) 15 | } 16 | 17 | fn build_request(uri: &str) -> Request { 18 | RequestBuilder::new(Method::Get, uri) 19 | .header("Accept", "application/json") 20 | .build() 21 | } 22 | -------------------------------------------------------------------------------- /aggregate-pattern/aggregates-service/src/response_parser.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | 3 | use crate::models::{ 4 | CustomerCountResponseModel, CustomerListModel, IncidentsGroupedByCustomerResponseModel, 5 | }; 6 | 7 | pub fn customer_count(body: &[u8]) -> anyhow::Result { 8 | serde_json::from_slice(body).with_context(|| { 9 | "Error converting response from downstream service to model (CustomerCountResponseModel)" 10 | }) 11 | } 12 | 13 | pub fn top_customers(body: &[u8]) -> anyhow::Result> { 14 | serde_json::from_slice(body).with_context(|| { 15 | "Error converting response from downstream service to model (TopCustomersResponseModel)" 16 | }) 17 | } 18 | 19 | pub fn incidents_grouped_by_customer( 20 | body: &[u8], 21 | ) -> anyhow::Result { 22 | serde_json::from_slice(body) 23 | .with_context(|| "Error converting response from downstream service to model (IncidentsGroupedByCustomerResponseModel)") 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::*; 29 | 30 | #[test] 31 | pub fn test_top_customer_deserialization() { 32 | let json = r#"[ 33 | { 34 | "id": "9AC27BB7-BDDF-E108-6B44-E1C4ACD84E97", 35 | "name": "Neque Vitae Corporation", 36 | "country": "India", 37 | "scoring": 10 38 | }, 39 | { 40 | "id": "1CC638E3-198F-26BA-136E-AD33AA044DED", 41 | "name": "Auctor Non Corp.", 42 | "country": "Philippines", 43 | "scoring": 9 44 | }, 45 | { 46 | "id": "B973CAF9-357C-7CC8-36F9-EAA25F839207", 47 | "name": "Duis Associates", 48 | "country": "Italy", 49 | "scoring": 9 50 | }, 51 | { 52 | "id": "1346371B-5270-84D9-4CE4-96B4CF785867", 53 | "name": "Eu Enim Etiam Foundation", 54 | "country": "Netherlands", 55 | "scoring": 9 56 | }, 57 | { 58 | "id": "BE45A517-2575-0669-D58C-C8A52D2BC41E", 59 | "name": "Id Risus Associates", 60 | "country": "Austria", 61 | "scoring": 9 62 | } 63 | ]"#; 64 | 65 | assert!(top_customers(json.as_bytes()).is_ok()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /aggregate-pattern/customers-service/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | target 4 | .spin/ 5 | build/ -------------------------------------------------------------------------------- /aggregate-pattern/customers-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "customers-service", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx webpack && mkdirp dist && j2w -i build/bundle.js -o dist/customers-service.wasm", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "mkdirp": "^3.0.1", 15 | "ts-loader": "^9.4.1", 16 | "typescript": "^4.8.4", 17 | "webpack": "^5.74.0", 18 | "webpack-cli": "^4.10.0" 19 | }, 20 | "dependencies": { 21 | "@spinframework/build-tools": "^1.0.1", 22 | "@spinframework/spin-sqlite": "^1.0.0", 23 | "@spinframework/wasi-http-proxy": "^1.0.0", 24 | "hono": "^4.7.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /aggregate-pattern/customers-service/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import type { Context } from 'hono' 3 | import { getAllItems, getCustomerById, getCustomerCount, getTopCustomers } from './handlers'; 4 | 5 | let app = new Hono(); 6 | 7 | app.get("/customers/count", (c: Context) => getCustomerCount(c)); 8 | app.get("/customers/top/:limit", (c: Context) => getTopCustomers(c)); 9 | app.get("/customers/items", (c: Context) => getAllItems(c)); 10 | app.get("/customers/items/:id", (c: Context) => getCustomerById(c)); 11 | 12 | app.fire(); 13 | 14 | -------------------------------------------------------------------------------- /aggregate-pattern/customers-service/src/models.ts: -------------------------------------------------------------------------------- 1 | export interface CustomerListModel { 2 | id: String, 3 | name: String, 4 | country: String, 5 | scoring: Number 6 | } 7 | 8 | export interface CustomerDetailsModel { 9 | id: String, 10 | name: String, 11 | city: String, 12 | country: String, 13 | scoring: Number 14 | } -------------------------------------------------------------------------------- /aggregate-pattern/customers-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es2020", 7 | "jsx": "react", 8 | "skipLibCheck": true, 9 | "lib": [ 10 | "ES2020", 11 | "WebWorker" 12 | ], 13 | "allowJs": true, 14 | "strict": true, 15 | "noImplicitReturns": true, 16 | "moduleResolution": "node" 17 | }, 18 | "include": [ 19 | "src/**/*" 20 | ] 21 | } -------------------------------------------------------------------------------- /aggregate-pattern/customers-service/webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import SpinSdkPlugin from "@spinframework/build-tools/plugins/webpack/index.js"; 3 | 4 | const config = async () => { 5 | let SpinPlugin = await SpinSdkPlugin.init() 6 | return { 7 | mode: 'production', 8 | stats: 'errors-only', 9 | entry: './src/index.ts', 10 | experiments: { 11 | outputModule: true, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.tsx?$/, 17 | use: 'ts-loader', 18 | exclude: /node_modules/, 19 | }, 20 | ], 21 | }, 22 | resolve: { 23 | extensions: ['.tsx', '.ts', '.js'], 24 | }, 25 | output: { 26 | path: path.resolve(process.cwd(), './build'), 27 | filename: 'bundle.js', 28 | module: true, 29 | library: { 30 | type: "module", 31 | } 32 | }, 33 | plugins: [ 34 | SpinPlugin 35 | ], 36 | optimization: { 37 | minimize: false 38 | }, 39 | performance: { 40 | hints: false, 41 | } 42 | }; 43 | } 44 | export default config -------------------------------------------------------------------------------- /aggregate-pattern/incidents-service/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /aggregate-pattern/incidents-service/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fermyon/enterprise-architectures-and-patterns/aggregate-pattern/incidents_service 2 | 3 | go 1.23 4 | 5 | require github.com/fermyon/spin/sdk/go/v2 v2.2.0 6 | 7 | require github.com/julienschmidt/httprouter v1.3.0 // indirect 8 | -------------------------------------------------------------------------------- /aggregate-pattern/incidents-service/go.sum: -------------------------------------------------------------------------------- 1 | github.com/fermyon/spin/sdk/go/v2 v2.2.0 h1:zHZdIqjbUwyxiwdygHItnM+vUUNSZ3CX43jbIUemBI4= 2 | github.com/fermyon/spin/sdk/go/v2 v2.2.0/go.mod h1:kfJ+gdf/xIaKrsC6JHCUDYMv2Bzib1ohFIYUzvP+SCw= 3 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 4 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 5 | -------------------------------------------------------------------------------- /aggregate-pattern/incidents-service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/fermyon/enterprise-architectures-and-patterns/aggregate-pattern/incidents_service/pkg/api" 7 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 8 | ) 9 | 10 | func init() { 11 | spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { 12 | router := api.New() 13 | router.ServeHTTP(w, r) 14 | }) 15 | } 16 | 17 | func main() {} 18 | -------------------------------------------------------------------------------- /aggregate-pattern/incidents-service/pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 8 | ) 9 | 10 | func New() *spinhttp.Router { 11 | r := spinhttp.NewRouter() 12 | r.GET("/incidents/items", getAllIncidents) 13 | r.GET("/incidents/items/:id", getIncidentById) 14 | r.GET("/incidents/grouped-by-customer", getIncidentsGroupedByCustomer) 15 | return r 16 | } 17 | 18 | func sendAsJson(w http.ResponseWriter, data interface{}) { 19 | header := w.Header() 20 | header.Set("Content-Type", "application/json") 21 | 22 | encoder := json.NewEncoder(w) 23 | encoder.SetIndent("", " ") 24 | encoder.Encode(data) 25 | } 26 | -------------------------------------------------------------------------------- /aggregate-pattern/incidents-service/pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type IncidentListModel struct { 4 | Id string `json:"id"` 5 | Amount float64 `json:"amount"` 6 | CustomerName string `json:"customerName"` 7 | } 8 | 9 | type IncidentDetailsModel struct { 10 | Id string `json:"id"` 11 | Amount float64 `json:"amount"` 12 | CustomerName string `json:"customerName"` 13 | Category string `json:"category"` 14 | } 15 | -------------------------------------------------------------------------------- /aggregate-pattern/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "aggregation-pattern" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "Sample implementation of the Aggregation Pattern" 8 | 9 | [[trigger.http]] 10 | route = "/aggregates/..." 11 | component = "aggregates-service" 12 | 13 | [component.aggregates-service] 14 | source = "aggregates-service/target/wasm32-wasi/release/aggregates_service.wasm" 15 | allowed_outbound_hosts = [ 16 | "http://customers-service.spin.internal", 17 | "http://incidents-service.spin.internal", 18 | ] 19 | 20 | [component.aggregates-service.build] 21 | command = "cargo build --target wasm32-wasip1 --release" 22 | workdir = "aggregates-service" 23 | watch = ["src/**/*.rs", "Cargo.toml"] 24 | 25 | [[trigger.http]] 26 | route = "/customers/..." 27 | component = "customers-service" 28 | 29 | [component.customers-service] 30 | source = "customers-service/target/customers-service.wasm" 31 | sqlite_databases = ["default"] 32 | 33 | [component.customers-service.build] 34 | command = "npm install && npm run build" 35 | workdir = "customers-service" 36 | 37 | [[trigger.http]] 38 | route = "/incidents/..." 39 | component = "incidents-service" 40 | 41 | [component.incidents-service] 42 | source = "incidents-service/main.wasm" 43 | sqlite_databases = ["default"] 44 | allowed_outbound_hosts = [] 45 | 46 | [component.incidents-service.build] 47 | command = "tinygo build -target=wasip1 -gc=leaking -buildmode=c-shared -no-debug -o main.wasm ." 48 | workdir = "incidents-service" 49 | watch = ["**/*.go", "go.mod"] 50 | -------------------------------------------------------------------------------- /api-with-cronjob/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /api-with-cronjob/README.md: -------------------------------------------------------------------------------- 1 | # HTTP API with CronJob 2 | 3 | This sample illustrates how you can use the [Spin Command Trigger](https://github.com/fermyon/spin-trigger-command) to one time commands with Spin. 4 | 5 | In the context of Kubernetes we can turn one-time commands into Jobs and CronJobs to perform individual tasks once or on a schedule. 6 | 7 | The sample consists of two Spin Apps: 8 | 9 | * API: A simple HTTP API that interacts with a key-value store 10 | * CRON: A command app which wipes data from the key-value store 11 | 12 | The API exposes the following endpoints 13 | 14 | * `GET /value` - Returns the value of a counter from key-value store 15 | * `POST /value` - Increments the counter in key-value store by `1` 16 | * `DELETE /value` - Removes the counter from the key-value store 17 | * `GET /` - Prints available API endpoints 18 | 19 | The Command App leverages Spin's key-value store API to load all keys available in the key-value store and deletes them. 20 | 21 | ## Supported Platforms 22 | 23 | - Local (`spin up`) 24 | - SpinKube 25 | - Fermyon Platform for Kubernetes 26 | 27 | ## Prerequisites 28 | 29 | To use this sample you must have 30 | 31 | - [Rust](https://www.rust-lang.org/) installed on your machine 32 | - The `wasm32-wasi` target for Rust installed (`rustup target add wasm32-wasi`) 33 | - [Spin](https://developer.fermyon.com/spin/v2/index) CLI installed on your machine 34 | - [Command Trigger Plugin](https://github.com/fermyon/spin-trigger-command) must be installed 35 | 36 | 37 | ## Running the Sample 38 | 39 | ### Local (`spin up`) 40 | 41 | Follow the steps outlined below to run the API and the Command App: 42 | 43 | - Start the API using `spin up -f spin.toml --runtime-config-file ./local.rtc.toml` 44 | - Increment the counter by sending `POST` requests to `/value`: `curl -X POST localhost:3000/value` 45 | - Check the value of the counter using `curl localhost:3000/value` (you should see a value other than `0`) 46 | - Run the Command App from a clean terminal instance using `spin up -f spin-cron.toml --runtime-config-file ./local.rtc.toml` 47 | - Check the value of the counter using `curl localhost:3000/value` (it should now return `0`) 48 | 49 | ### Running on Kubernetes with SpinKube 50 | 51 | The [`k8s`](./k8s) folder contains a `deploy.sh` script that you can use to deploy the sample to your Kubernetes cluster. 52 | 53 | > You must have [SpinKube](https://spinkube.dev) installed 54 | 55 | The script itself deploys the following artifacts to your cluster: 56 | 57 | - Redis will be deployed to the `redis` namespace 58 | - The Runtime Configuration File will be stored in the `rtc` secret in the `default` namespace 59 | - The API will be deployed to the `default` namespace 60 | - The Command App will be deployed as a CronJob to the `default` namespace and will be executed every 2nd minute 61 | -------------------------------------------------------------------------------- /api-with-cronjob/api/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /api-with-cronjob/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | serde = { version = "1.0.203", features = ["derive"] } 14 | serde_json = "1.0.117" 15 | spin-sdk = "3.1.1" 16 | 17 | [workspace] 18 | -------------------------------------------------------------------------------- /api-with-cronjob/api/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use spin_sdk::http::conversions::IntoBody; 3 | 4 | #[derive(Default, Serialize, Deserialize)] 5 | pub struct Counter { 6 | pub count: i32, 7 | } 8 | 9 | impl IntoBody for Counter { 10 | fn into_body(self) -> Vec { 11 | serde_json::to_vec(&self).unwrap_or_default() 12 | } 13 | } 14 | 15 | pub const COUNTER_KEY: &str = "spin_counter"; 16 | -------------------------------------------------------------------------------- /api-with-cronjob/cronjob/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /api-with-cronjob/cronjob/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cronjob" 3 | authors = ["Fermyon Engineering "] 4 | version = "0.1.0" 5 | edition = "2021" 6 | 7 | [package.metadata.component] 8 | package = "component:cronjob" 9 | 10 | [package.metadata.component.dependencies] 11 | 12 | [dependencies] 13 | anyhow = "1.0.86" 14 | spin-sdk = "3.1.1" 15 | wit-bindgen-rt = { version = "0.26.0", features = ["bitflags"] } 16 | -------------------------------------------------------------------------------- /api-with-cronjob/cronjob/src/bindings.rs: -------------------------------------------------------------------------------- 1 | // Generated by `wit-bindgen` 0.25.0. DO NOT EDIT! 2 | // Options used: 3 | 4 | #[cfg(target_arch = "wasm32")] 5 | #[link_section = "component-type:wit-bindgen:0.25.0:cronjob:encoded world"] 6 | #[doc(hidden)] 7 | pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 161] = *b"\ 8 | \0asm\x0d\0\x01\0\0\x19\x16wit-component-encoding\x04\0\x07$\x01A\x02\x01A\0\x04\ 9 | \x01\x19component:cronjob/cronjob\x04\0\x0b\x0d\x01\0\x07cronjob\x03\0\0\0G\x09p\ 10 | roducers\x01\x0cprocessed-by\x02\x0dwit-component\x070.208.1\x10wit-bindgen-rust\ 11 | \x060.25.0"; 12 | 13 | #[inline(never)] 14 | #[doc(hidden)] 15 | #[cfg(target_arch = "wasm32")] 16 | pub fn __link_custom_section_describing_imports() { 17 | wit_bindgen_rt::maybe_link_cabi_realloc(); 18 | } 19 | -------------------------------------------------------------------------------- /api-with-cronjob/cronjob/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use spin_sdk::{key_value::Store, variables}; 3 | 4 | #[allow(warnings)] 5 | mod bindings; 6 | 7 | fn main() -> Result<()> { 8 | println!("Loading Spin Application Variables"); 9 | let store_name = variables::get("store")?; 10 | println!("Accessing key-value store: '{}'", store_name); 11 | let store = Store::open(store_name.as_str())?; 12 | println!("Key-value store {} opened", store_name); 13 | let keys = store.get_keys()?; 14 | let _: Vec<_> = keys 15 | .into_iter() 16 | .filter_map(|key| { 17 | println!("Key-value store has key '{}'", key); 18 | match store.delete(key.as_str()) { 19 | Ok(_) => Some(key), 20 | Err(_) => None, 21 | } 22 | }) 23 | .inspect(|key| println!("Removed key '{}' from key-value store", key)) 24 | .collect(); 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /api-with-cronjob/k8s/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -euo pipefail 4 | 5 | current_directory=$(pwd) 6 | 7 | REGISTRY=ttl.sh 8 | TAG=12h 9 | 10 | APP_ARTIFACT=$REGISTRY/spin-api:$TAG 11 | CRON_ARTIFACT=$REGISTRY/spin-cron:$TAG 12 | 13 | SCHEDULE="*/2 * * * *" # Every 2nd minute 14 | 15 | # Deploy Redis 16 | helm upgrade --install redis oci://registry-1.docker.io/bitnamicharts/redis -n redis --create-namespace 17 | 18 | # Grab the redis secret 19 | redis_password=$(kubectl get secret --namespace redis redis -o jsonpath="{.data.redis-password}" | base64 -d) 20 | 21 | # Create a secret for the Spin App and the Cron Job 22 | sed "s|PASSWORD|${redis_password}|g" ./k8s.rtc.tmpl > ./runtime-config.toml 23 | kubectl delete secret rtc --ignore-not-found true 24 | kubectl create secret generic rtc --from-file=./runtime-config.toml 25 | 26 | # Build & Push the Spin App and the Spin Cron App 27 | 28 | cd .. 29 | spin registry push --build -f spin.toml $APP_ARTIFACT 30 | spin registry push --build -f spin-cron.toml $CRON_ARTIFACT 31 | 32 | cd $current_directory 33 | # Deploy the Spin App 34 | sed "s|ARTIFACT|${APP_ARTIFACT}|g" ./spin-app.tmpl > ./spin-app.yaml 35 | kubectl apply -f ./spin-app.yaml 36 | 37 | # Deploy the Spin Cron Trigger 38 | sed -e "s|ARTIFACT|${CRON_ARTIFACT}|g" -e "s|SCHEDULE|${SCHEDULE}|g" ./spin-cron.tmpl > ./spin-cron.yaml 39 | kubectl apply -f ./spin-cron.yaml 40 | 41 | # Delete generated files again 42 | rm spin-cron.yaml 43 | rm spin-app.yaml 44 | rm runtime-config.toml 45 | -------------------------------------------------------------------------------- /api-with-cronjob/k8s/k8s.rtc.tmpl: -------------------------------------------------------------------------------- 1 | [key_value_store.custom] 2 | type = "redis" 3 | url = "redis://:PASSWORD@redis-master.redis.svc.cluster.local" 4 | -------------------------------------------------------------------------------- /api-with-cronjob/k8s/spin-app.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: core.spinoperator.dev/v1alpha1 2 | kind: SpinApp 3 | metadata: 4 | name: api 5 | spec: 6 | image: ARTIFACT 7 | executor: containerd-shim-spin 8 | replicas: 1 9 | runtimeConfig: 10 | loadFromSecret: rtc 11 | -------------------------------------------------------------------------------- /api-with-cronjob/k8s/spin-cron.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: CronJob 3 | metadata: 4 | name: spin-cronjob 5 | spec: 6 | schedule: "SCHEDULE" 7 | jobTemplate: 8 | metadata: 9 | name: spin-cronjob 10 | spec: 11 | template: 12 | spec: 13 | runtimeClassName: wasmtime-spin-v2 14 | containers: 15 | - image: ARTIFACT 16 | command: 17 | - / 18 | name: main 19 | volumeMounts: 20 | - mountPath: /runtime-config.toml 21 | name: spin-runtime-config 22 | readOnly: true 23 | subPath: runtime-config.toml 24 | restartPolicy: OnFailure 25 | volumes: 26 | - name: spin-runtime-config 27 | secret: 28 | defaultMode: 420 29 | items: 30 | - key: runtime-config.toml 31 | path: runtime-config.toml 32 | optional: true 33 | secretName: rtc 34 | -------------------------------------------------------------------------------- /api-with-cronjob/local.rtc.toml: -------------------------------------------------------------------------------- 1 | [key_value_store.custom] 2 | type = "spin" 3 | path = ".spin/custom_kv_store" 4 | -------------------------------------------------------------------------------- /api-with-cronjob/spin-cron.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "cronjob" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | store = { default = "custom" } 11 | 12 | [[trigger.command]] 13 | component = "cronjob" 14 | 15 | [component.cronjob] 16 | source = "cronjob/target/wasm32-wasi/release/cronjob.wasm" 17 | key_value_stores = ["custom"] 18 | 19 | [component.cronjob.variables] 20 | store = "{{ store }}" 21 | 22 | [component.cronjob.build] 23 | command = "cargo component build --target wasm32-wasip1 --release" 24 | workdir = "cronjob" 25 | watch = ["src/**/*.rs", "Cargo.toml"] 26 | -------------------------------------------------------------------------------- /api-with-cronjob/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "api" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | store = { default = "custom" } 11 | 12 | [[trigger.http]] 13 | route = "/..." 14 | component = "api" 15 | 16 | [component.api] 17 | source = "api/target/wasm32-wasi/release/api.wasm" 18 | allowed_outbound_hosts = [] 19 | key_value_stores = ["custom"] 20 | 21 | [component.api.variables] 22 | store = "{{ store }}" 23 | 24 | [component.api.build] 25 | command = "cargo build --target wasm32-wasip1 --release" 26 | workdir = "api" 27 | watch = ["src/**/*.rs", "Cargo.toml"] 28 | -------------------------------------------------------------------------------- /application-variable-providers/README.md: -------------------------------------------------------------------------------- 1 | # Application Variable Providers 2 | 3 | Loading application variables from external systems is a common practice. Besides using the Environment Variable Provider, Spin allows you to load application variables using either the Vault Provider or the Azure Key Vault Provider. 4 | 5 | See examples for the corresponding Application Variable Providers in [`vault-provider`](./vault-provider/) and [`azure-key-vault-provider](./azure-key-vault-provider/) -------------------------------------------------------------------------------- /application-variable-providers/azure-key-vault-provider/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /application-variable-providers/azure-key-vault-provider/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "azure-key-vault-provider" 3 | authors = ["Fermyon Engineering "] 4 | description = "Azure Key Vault Application Variable Provider Example" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | spin-sdk = "3.1.1" 14 | 15 | [workspace] 16 | -------------------------------------------------------------------------------- /application-variable-providers/azure-key-vault-provider/README.md: -------------------------------------------------------------------------------- 1 | This example relates to the [Azure Key Vault Application Variable Provider Example](https://developer.fermyon.com/spin/v2/dynamic-configuration#azure-keyvault-application-variable-provider-example) documentation. 2 | 3 | You are best to visit the above link for more information, but for convenience, below is the steps taken to set up the Vault side of the application: 4 | 5 | 6 | ## 1. Deploy Azure Key Vault 7 | 8 | ```bash 9 | # Variable Definition 10 | KV_NAME=spin123 11 | LOCATION=germanywestcentral 12 | RG_NAME=rg-spin-azure-key-vault 13 | 14 | # Create Azure Resource Group and Azure Key Vault 15 | az group create -n $RG_NAME -l $LOCATION 16 | az keyvault create -n $KV_NAME \ 17 | -g $RG_NAME \ 18 | -l $LOCATION \ 19 | --enable-rbac-authorization true 20 | 21 | # Grab the Azure Resource Identifier of the Azure Key Vault instance 22 | KV_SCOPE=$(az keyvault show -n $KV_NAME -g $RG_NAME -otsv --query "id") 23 | ``` 24 | 25 | ## 2. Add a Secret to the Azure Key Vault instance 26 | 27 | ```bash 28 | # Grab the ID of the currently signed in user in Azure CLI 29 | CURRENT_USER_ID=$(az ad signed-in-user show -otsv --query "id") 30 | 31 | # Make the currently signed in user a Key Vault Secrets Officer 32 | # on the scope of the new Azure Key Vault instance 33 | az role assignment create --assignee $CURRENT_USER_ID \ 34 | --role "Key Vault Secrets Officer" \ 35 | --scope $KV_SCOPE 36 | 37 | # Create a test secret called 'secret` in the Azure Key Vault instance 38 | az keyvault secret set -n secret --vault-name $KV_NAME --value secret_value -o none 39 | ``` 40 | 41 | ## 3. Create a Service Principal and Role Assignment for Spin: 42 | 43 | ```bash 44 | SP_NAME=sp-spin 45 | SP=$(az ad sp create-for-rbac -n $SP_NAME -ojson) 46 | 47 | CLIENT_ID=$(echo $SP | jq -r '.appId') 48 | CLIENT_SECRET=$(echo $SP | jq -r '.password') 49 | TENANT_ID=$(echo $SP | jq -r '.tenant') 50 | 51 | az role assignment create --assignee $CLIENT_ID \ 52 | --role "Key Vault Secrets User" \ 53 | --scope $KV_SCOPE 54 | ``` 55 | 56 | ## 4. Replace Tokens in `runtime_config.toml` 57 | 58 | This folder contains a Runtime Configuration File (`runtime_config.toml`). Replace all tokens (e.g. `$KV_NAME$`) with the corresponding shell variables you created in the previous steps. 59 | 60 | ## 5. Build and run the `azure-key-vault-variable-test` app: 61 | 62 | ```bash 63 | spin build 64 | spin up --runtime-config-file runtime_config.toml 65 | ``` 66 | 67 | ## 6. Test the app: 68 | 69 | 70 | ```bash 71 | curl localhost:3000 72 | Loaded Secret from Azure Key Vault: secret_value 73 | ``` 74 | -------------------------------------------------------------------------------- /application-variable-providers/azure-key-vault-provider/runtime_config.toml: -------------------------------------------------------------------------------- 1 | [[config_provider]] 2 | type = "azure_key_vault" 3 | vault_url = "https://$KV_NAME$.vault.azure.net/" 4 | client_id = "$CLIENT_ID$" 5 | client_secret = "$CLIENT_SECRET$" 6 | tenant_id = "$TENANT_ID$" 7 | authority_host = "AzurePublicCloud" 8 | -------------------------------------------------------------------------------- /application-variable-providers/azure-key-vault-provider/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "azure-key-vault-provider" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "Using the Azure Key Vault Application Variable Provider" 8 | 9 | [variables] 10 | secret = { required = true } 11 | 12 | 13 | [[trigger.http]] 14 | route = "/..." 15 | component = "azure-key-vault-provider" 16 | 17 | [component.azure-key-vault-provider] 18 | source = "target/wasm32-wasi/release/azure_key_vault_provider.wasm" 19 | allowed_outbound_hosts = [] 20 | 21 | [component.azure-key-vault-provider.variables] 22 | secret = "{{ secret }}" 23 | 24 | [component.azure-key-vault-provider.build] 25 | command = "cargo build --target wasm32-wasip1 --release" 26 | watch = ["src/**/*.rs", "Cargo.toml"] 27 | -------------------------------------------------------------------------------- /application-variable-providers/azure-key-vault-provider/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use spin_sdk::http::{IntoResponse, Request, Response}; 3 | use spin_sdk::{http_component, variables}; 4 | 5 | #[http_component] 6 | fn handle_azure_key_vault_provider(_req: Request) -> anyhow::Result { 7 | let value = variables::get("secret").context("could not get variable")?; 8 | 9 | Ok(Response::builder() 10 | .status(200) 11 | .header("content-type", "text/plain") 12 | .body(format!("Loaded secret from Azure Key Vault: {}", value)) 13 | .build()) 14 | } 15 | -------------------------------------------------------------------------------- /application-variable-providers/vault-provider/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /application-variable-providers/vault-provider/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "vault-provider" 3 | authors = ["Fermyon Engineering "] 4 | description = "Vault Application Variable Provider Example" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | spin-sdk = "3.1.1" 14 | constant_time_eq = "0.3.0" 15 | 16 | [workspace] 17 | -------------------------------------------------------------------------------- /application-variable-providers/vault-provider/README.md: -------------------------------------------------------------------------------- 1 | This example relates to the [Vault Application Variable Provider Example](https://developer.fermyon.com/spin/v2/dynamic-configuration#vault-application-variable-provider-example) documentation. 2 | 3 | You are best to visit the above link for more information, but for convenience, below is the steps taken to set up the Vault side of the application: 4 | 5 | 1. [Install Vault](https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-install) 6 | 7 | 2. Start Vault: 8 | 9 | ```bash 10 | vault server -dev -dev-root-token-id root 11 | ``` 12 | 13 | 3. Set a token in Vault: 14 | 15 | ```bash 16 | export VAULT_TOKEN=root 17 | export VAULT_ADDR=http://127.0.0.1:8200 18 | export TOKEN=eyMyJWTToken... 19 | vault kv put secret/secret value=$TOKEN 20 | vault kv get secret/secret 21 | ``` 22 | 23 | 4. Build the application: 24 | 25 | ```bash 26 | spin build 27 | ``` 28 | 29 | 5. Run the application: 30 | 31 | ```bash 32 | spin up --runtime-config-file runtime_config.toml 33 | ``` 34 | 35 | 6. Test the application: 36 | 37 | ```bash 38 | $ curl localhost:3000 --data $TOKEN 39 | {"authentication": "accepted"} 40 | ``` -------------------------------------------------------------------------------- /application-variable-providers/vault-provider/runtime_config.toml: -------------------------------------------------------------------------------- 1 | [[config_provider]] 2 | type = "vault" 3 | url = "http://127.0.0.1:8200" 4 | token = "root" 5 | mount = "secret" -------------------------------------------------------------------------------- /application-variable-providers/vault-provider/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "vault-provider" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "Vault Application Variable Provider Example" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "vault-provider" 12 | 13 | [variables] 14 | secret = { required = true } 15 | 16 | [component.vault-provider] 17 | source = "target/wasm32-wasi/release/vault_provider.wasm" 18 | allowed_outbound_hosts = [] 19 | 20 | [component.vault-provider.variables] 21 | token = "{{ secret }}" 22 | 23 | [component.vault-provider.build] 24 | command = "cargo build --target wasm32-wasip1 --release" 25 | watch = ["src/**/*.rs", "Cargo.toml"] 26 | -------------------------------------------------------------------------------- /application-variable-providers/vault-provider/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use constant_time_eq::constant_time_eq; 3 | use spin_sdk::{ 4 | http::{IntoResponse, Request, Response}, 5 | http_component, variables, 6 | }; 7 | 8 | #[http_component] 9 | fn handle_vault_variable_test(req: Request) -> Result { 10 | let attempt = std::str::from_utf8(req.body()).unwrap(); 11 | let expected = variables::get("token").context("could not get variable")?; 12 | let response = if constant_time_eq(&expected.into_bytes(), attempt.as_bytes()) { 13 | "accepted" 14 | } else { 15 | "denied" 16 | }; 17 | let response_json = format!("{{\"authentication\": \"{}\"}}", response); 18 | Ok(Response::builder() 19 | .status(200) 20 | .header("content-type", "application/json") 21 | .body(response_json) 22 | .build()) 23 | } 24 | -------------------------------------------------------------------------------- /caching-rust/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /caching-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "caching-rust" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | md-5 = "0.10.6" 14 | serde = { version = "1.0.197", features = ["derive"] } 15 | serde_json = "1.0.114" 16 | spin-sdk = "3.1.1" 17 | uuid = { version = "1.7.0", features = ["v4"] } 18 | 19 | [workspace] 20 | -------------------------------------------------------------------------------- /caching-rust/local.toml: -------------------------------------------------------------------------------- 1 | [key_value_store.cache] 2 | type = "spin" 3 | path = ".spin/cache.db" 4 | -------------------------------------------------------------------------------- /caching-rust/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS ITEMS ( 2 | ID varchar(36) PRIMARY KEY, 3 | NAME TEXT NOT NULL 4 | ); 5 | 6 | INSERT INTO ITEMS(ID, NAME) 7 | SELECT '8b933c84-ee60-45a1-848d-428ad3259e2b', 'Green Apples' 8 | WHERE 9 | NOT EXISTS ( 10 | SELECT ID FROM ITEMS WHERE ID = '8b933c84-ee60-45a1-848d-428ad3259e2b' 11 | ); 12 | 13 | INSERT INTO ITEMS(ID, NAME) 14 | SELECT 'd660b9b2-0406-46d6-9efe-b40b4cca59fc', 'Bananas' 15 | WHERE 16 | NOT EXISTS ( 17 | SELECT ID FROM ITEMS WHERE ID = 'd660b9b2-0406-46d6-9efe-b40b4cca59fc' 18 | ); -------------------------------------------------------------------------------- /caching-rust/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "caching-rust" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "caching-rust" 12 | 13 | [component.caching-rust] 14 | source = "target/wasm32-wasi/release/caching_rust.wasm" 15 | allowed_outbound_hosts = [] 16 | key_value_stores = ["cache"] 17 | sqlite_databases = ["default"] 18 | 19 | [component.caching-rust.build] 20 | command = "cargo build --target wasm32-wasip1 --release" 21 | watch = ["src/**/*.rs", "Cargo.toml"] 22 | -------------------------------------------------------------------------------- /caching-rust/src/cache.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use spin_sdk::key_value::Store; 3 | 4 | use crate::model::CacheKey; 5 | 6 | const STORE_NAME: &str = "cache"; 7 | 8 | pub(crate) fn get_from_cache(key: &CacheKey) -> Option { 9 | let open_default = Store::open(STORE_NAME); 10 | let Ok(store) = open_default else { 11 | return None; 12 | }; 13 | let Ok(data) = store.get(key.value.as_str()) else { 14 | return None; 15 | }; 16 | match data { 17 | Some(b) => String::from_utf8(b).ok(), 18 | None => None, 19 | } 20 | } 21 | 22 | pub(crate) fn store_in_cache(key: &CacheKey, data: String) { 23 | // ignore all errors because we want to return existing data 24 | // to the callee either way 25 | let Ok(store) = Store::open(STORE_NAME) else { 26 | return; 27 | }; 28 | let _ = store.set(key.value.as_str(), &data.as_bytes()); 29 | } 30 | 31 | pub(crate) fn invalidate_cache(key: &CacheKey) -> anyhow::Result<()> { 32 | let store = Store::open(STORE_NAME)?; 33 | match store.exists(&key.value)? { 34 | true => store 35 | .delete(&key.value) 36 | .with_context(|| "Error while removing data from cache"), 37 | false => Ok(()), 38 | } 39 | } 40 | 41 | pub(crate) fn invalidate_all() -> anyhow::Result<()> { 42 | let store = Store::open(STORE_NAME)?; 43 | let keys = store.get_keys()?; 44 | for key in &keys { 45 | store.delete(key)? 46 | } 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /caching-rust/src/db.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use spin_sdk::sqlite::{Connection, Value}; 3 | 4 | use crate::model::Item; 5 | 6 | pub(crate) fn get_all_from_database() -> anyhow::Result> { 7 | let con = Connection::open_default()?; 8 | let query_result = con.execute("SELECT ID, NAME FROM ITEMS", &[])?; 9 | let items: Vec = query_result 10 | .rows 11 | .iter() 12 | .map(|row| Item { 13 | id: row.get::<&str>(0).unwrap_or_default().to_string(), 14 | name: row.get::<&str>(1).unwrap_or_default().to_string(), 15 | }) 16 | .collect(); 17 | Ok(items) 18 | } 19 | 20 | pub(crate) fn get_single_from_database(id: String) -> anyhow::Result> { 21 | let con = Connection::open_default()?; 22 | let params = [Value::Text(id.clone())]; 23 | let query_result = con.execute("SELECT NAME FROM ITEMS WHERE ID = ?", ¶ms)?; 24 | let res = match query_result.rows().next() { 25 | None => None, 26 | Some(row) => Some(Item { 27 | id: id, 28 | name: row.get::<&str>("NAME").unwrap_or_default().to_string(), 29 | }), 30 | }; 31 | Ok(res) 32 | } 33 | 34 | pub(crate) fn update_single_item_in_database(id: String, name: String) -> anyhow::Result { 35 | let con = Connection::open_default()?; 36 | let params = [Value::Text(name.clone()), Value::Text(id.clone())]; 37 | con.execute("UPDATE ITEMS SET NAME=? WHERE ID = ?", ¶ms) 38 | .and(Ok(Item { id, name })) 39 | .with_context(|| "Error while updating item in database") 40 | } 41 | -------------------------------------------------------------------------------- /caching-rust/src/model.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | pub(crate) struct CacheKey { 3 | pub value: String, 4 | } 5 | 6 | #[derive(Debug, Deserialize)] 7 | pub(crate) struct UpdateItemModel { 8 | pub name: String, 9 | } 10 | 11 | #[derive(Debug, Deserialize, Serialize)] 12 | pub(crate) struct Item { 13 | pub id: String, 14 | pub name: String, 15 | } 16 | 17 | impl CacheKey { 18 | pub(crate) fn new(value: String) -> Self { 19 | CacheKey { value } 20 | } 21 | } 22 | 23 | impl From for CacheKey { 24 | fn from(value: Item) -> Self { 25 | CacheKey::new(format!("type-{}-id-{}", Item::get_cache_type(), value.id).to_lowercase()) 26 | } 27 | } 28 | 29 | impl From<(uuid::Uuid, &'static str)> for CacheKey { 30 | fn from(value: (uuid::Uuid, &'static str)) -> Self { 31 | CacheKey::new(format!("type-{}-id-{}", value.1, value.0).to_lowercase()) 32 | } 33 | } 34 | 35 | impl From<&Vec> for CacheKey { 36 | fn from(value: &Vec) -> Self { 37 | CacheKey::new( 38 | value 39 | .iter() 40 | .map(|d| d.id.clone()) 41 | .collect::>() 42 | .join("_") 43 | .to_lowercase(), 44 | ) 45 | } 46 | } 47 | 48 | pub trait CacheKeyProvider { 49 | fn get_cache_type() -> &'static str; 50 | fn get_cache_key_for_all() -> CacheKey; 51 | } 52 | 53 | impl CacheKeyProvider for Item { 54 | fn get_cache_type() -> &'static str { 55 | "Item" 56 | } 57 | 58 | fn get_cache_key_for_all() -> CacheKey { 59 | CacheKey::new(format!("all-of-type-{}", Item::get_cache_type())) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /content-negotiation-rust/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /content-negotiation-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "content-negotiation-rust" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | serde = { version = "1.0.197", features = ["derive"] } 14 | quick-xml = { version = "0.31", features = ["serialize"] } 15 | 16 | serde_json = "1.0.114" 17 | serde_yaml = "0.9.32" 18 | spin-sdk = "3.1.1" 19 | 20 | [workspace] 21 | -------------------------------------------------------------------------------- /content-negotiation-rust/README.md: -------------------------------------------------------------------------------- 1 | # Content Negotiation 2 | 3 | This folder contains a Content Negotiation implementation written in Rust. 4 | 5 | ## What is Content Negotiation 6 | 7 | Content negotiation is a crucial concept in API development that allows clients and servers to agree on the format and structure of exchanged data. It enables interoperability between different systems by enabling them to communicate using various data formats, such as JSON, XML, or even HTML. Typically, content negotiation occurs during the HTTP request process, where the client expresses its preferences for the data format through request headers like 'Accept'. The server then examines these preferences and selects the most suitable representation of the requested resource, taking into account factors like available formats and the client's stated preferences. 8 | 9 | Implementing content negotiation ensures that your APIs can cater to a diverse range of clients with varying capabilities and preferences. By supporting multiple data formats, you can reach a broader audience and accommodate different client needs without requiring separate endpoints for each format. Additionally, content negotiation promotes flexibility and future-proofing, as it allows you to introduce new data formats or modify existing ones without impacting clients that support different formats. Properly implemented content negotiation enhances the usability and accessibility of APIs, fostering seamless communication between clients and servers. 10 | 11 | ## Supported Platforms 12 | 13 | - Local (`spin up`) 14 | - Fermyon Cloud 15 | - SpinKube 16 | - Fermyon Platform for Kubernetes 17 | 18 | ## Prerequisites 19 | 20 | To use this sample you must have 21 | 22 | - [Rust](https://www.rust-lang.org/) installed on your machine 23 | - The `wasm32-wasi` target for Rust installed (`rustup target add wasm32-wasi`) 24 | - [Spin](https://developer.fermyon.com/spin/v2/index) CLI installed on your machine 25 | 26 | ## Exposed Endpoints 27 | 28 | The API exposes the following endpoints: 29 | 30 | - `GET /data`: Returns a list of items 31 | - `GET /data/:id`: Returns a single item (you can either provide any `string` or any int as `:id`) 32 | 33 | Supported content types (by specifying the `Content-Type` HTTP-header): 34 | 35 | - JSON: (`application/json`) 36 | - YAML: (`application/yaml`) 37 | - XML: (`application/xml`) 38 | - Plain Text: (`text/plain`) 39 | 40 | ## Running the Sample 41 | 42 | ### Local (`spin up`) 43 | 44 | The following snippet shows how to run the sample on your local machine: 45 | 46 | ```bash 47 | # Build the sample 48 | spin build 49 | 50 | # Run the sample 51 | spin up 52 | ``` 53 | 54 | ### Fermyon Cloud 55 | 56 | You can deploy this sample to Fermyon Cloud following the steps below: 57 | 58 | ```bash 59 | # Authenticate 60 | spin cloud login 61 | 62 | # Deploy the sample to Fermyon Cloud 63 | spin deploy 64 | ``` -------------------------------------------------------------------------------- /content-negotiation-rust/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "content-negotiation-rust" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "content-negotiation-rust" 12 | 13 | [component.content-negotiation-rust] 14 | source = "target/wasm32-wasi/release/content_negotiation_rust.wasm" 15 | allowed_outbound_hosts = [] 16 | [component.content-negotiation-rust.build] 17 | command = "cargo build --target wasm32-wasip1 --release" 18 | watch = ["src/**/*.rs", "Cargo.toml"] 19 | -------------------------------------------------------------------------------- /content-negotiation-rust/src/content_negotiation.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use anyhow::Context; 4 | use serde::Serialize; 5 | use spin_sdk::http::{conversions::IntoBody, Request, ResponseBuilder}; 6 | 7 | pub enum SupportedContentType { 8 | Xml, 9 | Json, 10 | Yaml, 11 | PlainText, 12 | } 13 | 14 | impl From<&str> for SupportedContentType { 15 | fn from(value: &str) -> Self { 16 | match value.to_lowercase().as_str() { 17 | "application/yaml" => SupportedContentType::Yaml, 18 | "application/xml" => SupportedContentType::Xml, 19 | "text/plain" => SupportedContentType::PlainText, 20 | _ => SupportedContentType::Json, 21 | } 22 | } 23 | } 24 | 25 | impl SupportedContentType { 26 | fn as_header_value(self) -> String { 27 | match self { 28 | SupportedContentType::Xml => String::from("application/xml"), 29 | SupportedContentType::Yaml => String::from("application/yaml"), 30 | SupportedContentType::Json => String::from("application/json"), 31 | SupportedContentType::PlainText => String::from("text/plain"), 32 | } 33 | } 34 | } 35 | 36 | pub trait Negotiate { 37 | fn negotiate(&mut self, req: &Request, data: &T) -> &mut Self 38 | where 39 | T: Serialize + Display; 40 | } 41 | 42 | impl Negotiate for ResponseBuilder { 43 | fn negotiate(&mut self, req: &Request, data: &T) -> &mut Self 44 | where 45 | T: Serialize + Display, 46 | { 47 | let ct = detect_content_type(req); 48 | match negotiate_content(data, &ct) { 49 | Ok(payload) => self 50 | .header("Content-Type", &ct.as_header_value()) 51 | .body(payload), 52 | Err(e) => self.status(500).body(format!("{}", e)), 53 | }; 54 | self 55 | } 56 | } 57 | 58 | fn detect_content_type(req: &Request) -> SupportedContentType { 59 | let Some(accept_header) = req.header("Accept") else { 60 | return SupportedContentType::Json; 61 | }; 62 | let Some(accept_header_value) = accept_header.as_str() else { 63 | return SupportedContentType::Json; 64 | }; 65 | SupportedContentType::from(accept_header_value) 66 | } 67 | 68 | fn negotiate_content( 69 | data: &T, 70 | content_type: &SupportedContentType, 71 | ) -> anyhow::Result 72 | where 73 | T: Serialize + Display, 74 | { 75 | match content_type { 76 | SupportedContentType::PlainText => Ok(format!("{}", data)), 77 | SupportedContentType::Json => { 78 | serde_json::to_string(data).with_context(|| "Error while producing JSON") 79 | } 80 | SupportedContentType::Yaml => { 81 | serde_yaml::to_string(data).with_context(|| "Error while producing YAML") 82 | } 83 | SupportedContentType::Xml => match quick_xml::se::to_string(data) { 84 | Ok(r) => Ok(r), 85 | Err(e) => { 86 | println!("{}", e); 87 | Ok(String::from("")) 88 | } 89 | }, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /content-negotiation-rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | use content_negotiation::Negotiate; 2 | use service::SampleService; 3 | use spin_sdk::{ 4 | http::{IntoResponse, Params, Request, Response, Router}, 5 | http_component, 6 | }; 7 | 8 | mod content_negotiation; 9 | mod models; 10 | mod service; 11 | /// A simple Spin HTTP component. 12 | #[http_component] 13 | fn handle_content_negotiation_rust(req: Request) -> anyhow::Result { 14 | let mut router = Router::default(); 15 | router.get("/data", handle_get_many); 16 | router.get("/data/:id", handle_get_single); 17 | router.get("*", handle_info); 18 | Ok(router.handle(req)) 19 | } 20 | 21 | fn handle_info(_: Request, _: Params) -> anyhow::Result { 22 | Ok(Response::builder() 23 | .status(200) 24 | .header("Content-Type", "text/plain") 25 | .body( 26 | r#"Please issue any of the following requests 27 | 28 | GET /data -> Returns a list of items 29 | GET /data/:id -> Returns a single item (you can either provide any string of any int as :id) 30 | 31 | Supported content types (by specifying the Content-Type header): 32 | 33 | - JSON (application/json) 34 | - YAML (application/yaml) 35 | - XML (application/xml) 36 | - Plain Text (text/plain) 37 | "#, 38 | ) 39 | .build()) 40 | } 41 | fn handle_get_many(req: Request, _: Params) -> anyhow::Result { 42 | let data = SampleService::get_data(); 43 | 44 | Ok(Response::builder() 45 | .status(200) 46 | .negotiate(&req, &data) 47 | .build()) 48 | } 49 | 50 | fn handle_get_single(req: Request, _: Params) -> anyhow::Result { 51 | let data = SampleService::get_single(); 52 | 53 | Ok(Response::builder() 54 | .status(200) 55 | .negotiate(&req, &data) 56 | .build()) 57 | } 58 | -------------------------------------------------------------------------------- /content-negotiation-rust/src/models.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use serde::Serialize; 4 | 5 | #[derive(Debug, Clone, Serialize, Default)] 6 | pub struct Values { 7 | #[serde(default, rename = "values")] 8 | pub values: Vec, 9 | } 10 | 11 | impl Display for Values { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | let display = self 14 | .values 15 | .clone() 16 | .into_iter() 17 | .map(|i| format!("{}", i)) 18 | .collect::>() 19 | .join(""); 20 | write!(f, "{}", display) 21 | } 22 | } 23 | 24 | #[derive(Debug, Clone, Serialize, PartialEq)] 25 | pub struct Value { 26 | pub message: String, 27 | #[serde(rename = "isFoo")] 28 | pub is_foo: bool, 29 | } 30 | 31 | impl Display for Value { 32 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 | write!(f, "Message: {} (foo: {})\n", self.message, self.is_foo) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /content-negotiation-rust/src/service.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{Value, Values}; 2 | 3 | pub(crate) struct SampleService {} 4 | 5 | impl SampleService { 6 | pub fn get_data() -> Values { 7 | Values { 8 | values: vec![ 9 | Value { 10 | message: String::from("Foo"), 11 | is_foo: true, 12 | }, 13 | Value { 14 | message: String::from("Bar"), 15 | is_foo: false, 16 | }, 17 | ], 18 | } 19 | } 20 | 21 | pub fn get_single() -> Value { 22 | Value { 23 | message: String::from("Baz"), 24 | is_foo: true, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cors-rust/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cors-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cors-rust" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1.0.98" 13 | spin-sdk = { version = "3.1.1", features = ["json"] } 14 | serde = { version = "1.0.219", features = ["derive"] } 15 | serde_json = "1.0.140" 16 | http = "1.3.1" 17 | spin-contrib-http = "0.0.8" 18 | [workspace] 19 | -------------------------------------------------------------------------------- /cors-rust/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS ITEMS 2 | (ID INTEGER PRIMARY KEY AUTOINCREMENT, 3 | NAME TEXT NOT NULL) -------------------------------------------------------------------------------- /cors-rust/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "cors-rust" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "cors-rust" 12 | 13 | [component.cors-rust] 14 | source = "target/wasm32-wasip1/release/cors_rust.wasm" 15 | allowed_outbound_hosts = [] 16 | sqlite_databases = ["default"] 17 | 18 | [variables] 19 | allowed_origins = { default = "http://localhost:4200" } 20 | [component.cors-rust.variables] 21 | cors_allowed_origins = "{{ allowed_origins }}" 22 | cors_allowed_methods = "*" 23 | cors_allowed_headers = "*" 24 | cors_allow_credentials = "true" 25 | cors_max_age = "3600" 26 | 27 | [component.cors-rust.build] 28 | command = "cargo build --target wasm32-wasip1 --release" 29 | watch = ["src/**/*.rs", "Cargo.toml"] 30 | -------------------------------------------------------------------------------- /cors-rust/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize)] 4 | pub(crate) struct Item { 5 | #[serde(skip_deserializing)] 6 | pub(crate) id: i64, 7 | pub(crate) name: String, 8 | } 9 | 10 | impl Item { 11 | pub(crate) fn new(id: i64, name: String) -> Self { 12 | Self { id, name } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /cqrs-go/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cqrs-go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cqrs_go 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/fermyon/spin/sdk/go/v2 v2.2.0 7 | github.com/google/uuid v1.6.0 8 | ) 9 | 10 | require github.com/julienschmidt/httprouter v1.3.0 // indirect 11 | -------------------------------------------------------------------------------- /cqrs-go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/fermyon/spin/sdk/go/v2 v2.2.0 h1:zHZdIqjbUwyxiwdygHItnM+vUUNSZ3CX43jbIUemBI4= 2 | github.com/fermyon/spin/sdk/go/v2 v2.2.0/go.mod h1:kfJ+gdf/xIaKrsC6JHCUDYMv2Bzib1ohFIYUzvP+SCw= 3 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 4 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 6 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 7 | -------------------------------------------------------------------------------- /cqrs-go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/cqrs_go/pkg/api" 7 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 8 | ) 9 | 10 | func init() { 11 | spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { 12 | router := api.NewApiFacade() 13 | router.ServeHTTP(w, r) 14 | }) 15 | } 16 | 17 | func main() {} 18 | -------------------------------------------------------------------------------- /cqrs-go/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS PRODUCTS ( 2 | ID VARCHAR(36) PRIMARY KEY, 3 | NAME TEXT NOT NULL, 4 | DESCRIPTION TEXT NOT NULL 5 | ); 6 | 7 | INSERT INTO PRODUCTS(ID, NAME, DESCRIPTION) 8 | SELECT '12a33c84-ee60-45a1-848d-428ad3259abc', 'Bacon', 'Everything tastes better, with bacon 🥓' 9 | WHERE 10 | NOT EXISTS ( 11 | SELECT ID FROM PRODUCTS WHERE ID = '12a33c84-ee60-45a1-848d-428ad3259abc' 12 | ); -------------------------------------------------------------------------------- /cqrs-go/pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 5 | ) 6 | 7 | func NewApiFacade() *spinhttp.Router { 8 | router := spinhttp.NewRouter() 9 | 10 | // register queries 11 | router.GET("/items", queryAllProducts) 12 | router.GET("/items/:id", queryProductById) 13 | 14 | // register commands 15 | router.POST("/items", createProduct) 16 | router.PUT("/items/:id", updateProduct) 17 | router.DELETE("/items/:id", deleteProduct) 18 | return router 19 | } 20 | -------------------------------------------------------------------------------- /cqrs-go/pkg/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/fermyon/spin/sdk/go/v2/sqlite" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | const dbName string = "default" 9 | 10 | // Request model for creating new products 11 | type CreateProductModel struct { 12 | Name string `json:"name"` 13 | Description string `json:"description"` 14 | } 15 | 16 | // Request model for updating a particular product 17 | type UpdateProductModel struct { 18 | Name string `json:"name"` 19 | Description string `json:"description"` 20 | } 21 | 22 | // Response model used once a product has been created 23 | type ProductCreatedModel struct { 24 | Id string `json:"id"` 25 | Name string `json:"name"` 26 | Description string `json:"description"` 27 | } 28 | 29 | // Response model used once a product has been updated 30 | type ProductUpdatedModel struct { 31 | Id string `json:"id"` 32 | Name string `json:"name"` 33 | Description string `json:"description"` 34 | } 35 | 36 | const ( 37 | commandCreateProduct = "INSERT INTO PRODUCTS (ID, NAME, DESCRIPTION) VALUES (?,?,?)" 38 | commandUpdateProduct = "UPDATE PRODUCTS SET NAME = ?, DESCRIPTION = ? WHERE ID = ? RETURNING ID" 39 | commandDeleteProduct = "DELETE FROM PRODUCTS WHERE ID = ? RETURNING ID" 40 | ) 41 | 42 | // Command to create a new product 43 | func CreateProduct(model CreateProductModel) (*ProductCreatedModel, error) { 44 | con := sqlite.Open(dbName) 45 | defer con.Close() 46 | id, err := uuid.NewRandom() 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | _, err = con.Exec(commandCreateProduct, id.String(), model.Name, model.Description) 52 | if err != nil { 53 | return nil, err 54 | } 55 | return &ProductCreatedModel{ 56 | Id: id.String(), 57 | Name: model.Name, 58 | Description: model.Description, 59 | }, nil 60 | } 61 | 62 | // Command to update a particular product 63 | func UpdateProduct(id string, model UpdateProductModel) (*ProductUpdatedModel, error) { 64 | con := sqlite.Open(dbName) 65 | defer con.Close() 66 | res, err := con.Query(commandUpdateProduct, model.Name, model.Description, id) 67 | if err != nil { 68 | return nil, err 69 | } 70 | updated := res.Next() 71 | if !updated { 72 | return nil, nil 73 | } 74 | 75 | return &ProductUpdatedModel{ 76 | Id: id, 77 | Name: model.Name, 78 | Description: model.Description, 79 | }, nil 80 | } 81 | 82 | // Command to delete a particular product 83 | func DeleteProduct(id string) (bool, error) { 84 | con := sqlite.Open(dbName) 85 | defer con.Close() 86 | res, err := con.Query(commandDeleteProduct, id) 87 | if err != nil { 88 | return false, err 89 | } 90 | updated := res.Next() 91 | if !updated { 92 | return false, nil 93 | } 94 | return true, nil 95 | } 96 | -------------------------------------------------------------------------------- /cqrs-go/pkg/queries/queries.go: -------------------------------------------------------------------------------- 1 | package queries 2 | 3 | import ( 4 | "github.com/fermyon/spin/sdk/go/v2/sqlite" 5 | ) 6 | 7 | const ( 8 | dbName = "default" 9 | queryAllProducts = "SELECT ID, NAME FROM PRODUCTS ORDER BY NAME ASC" 10 | queryProductById = "SELECT ID, NAME, DESCRIPTION FROM PRODUCTS WHERE ID = ?" 11 | ) 12 | 13 | // Response model used for a particular product, queried as part of a list 14 | type ProductListModel struct { 15 | Id string `json:"id"` 16 | Name string `json:"name"` 17 | } 18 | 19 | // Response model used for a particular product, queried using the product identifier 20 | type ProductDetailsModel struct { 21 | Id string `json:"id"` 22 | Name string `json:"name"` 23 | Description string `json:"description"` 24 | } 25 | 26 | // Query to retrieve all products as a list 27 | func AllProducts() ([]*ProductListModel, error) { 28 | products := make([]*ProductListModel, 0) 29 | 30 | con := sqlite.Open(dbName) 31 | defer con.Close() 32 | rows, err := con.Query(queryAllProducts) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | for rows.Next() { 38 | var product ProductListModel 39 | if err := rows.Scan(&product.Id, &product.Name); err != nil { 40 | return nil, err 41 | } 42 | products = append(products, &product) 43 | } 44 | return products, nil 45 | } 46 | 47 | // Query to retrieve a particular product using its identifier 48 | func ProductById(id string) (*ProductDetailsModel, error) { 49 | con := sqlite.Open(dbName) 50 | defer con.Close() 51 | rows, err := con.Query(queryProductById, id) 52 | if err != nil { 53 | return nil, err 54 | } 55 | found := rows.Next() 56 | if !found { 57 | return nil, nil 58 | } 59 | var product ProductDetailsModel 60 | if err := rows.Scan(&product.Id, &product.Name, &product.Description); err != nil { 61 | return nil, err 62 | } 63 | return &product, nil 64 | } 65 | -------------------------------------------------------------------------------- /cqrs-go/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "cqrs-go" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "A fairly simple CQRS sample" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "cqrs-go" 12 | 13 | [component.cqrs-go] 14 | source = "main.wasm" 15 | allowed_outbound_hosts = [] 16 | sqlite_databases = ["default"] 17 | 18 | [component.cqrs-go.build] 19 | command = "tinygo build -target=wasip1 -gc=leaking -buildmode=c-shared -no-debug -o main.wasm ." 20 | watch = ["**/*.go", "go.mod"] 21 | -------------------------------------------------------------------------------- /cqrs-rust/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cqrs-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cqrs-rust" 3 | version = { workspace = true } 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | 7 | [workspace.package] 8 | version = "0.1.0" 9 | authors = ["Fermyon Engineering "] 10 | edition = "2021" 11 | rust-version = "1.75" 12 | 13 | [lib] 14 | crate-type = ["cdylib"] 15 | 16 | [dependencies] 17 | anyhow = { workspace = true } 18 | spin-sdk = { workspace = true } 19 | serde_json = "1" 20 | uuid = { version = "1.7.0", features = ["serde", "v4"] } 21 | cqrs-commands = { path = "crates/commands" } 22 | cqrs-queries = { path = "crates/queries" } 23 | 24 | [workspace] 25 | members = ["crates/*"] 26 | 27 | [workspace.dependencies] 28 | anyhow = "1" 29 | serde = { version = "1", features = ["derive"] } 30 | spin-sdk = "3.0.1" 31 | -------------------------------------------------------------------------------- /cqrs-rust/crates/commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cqrs-commands" 3 | version = { workspace = true } 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | 7 | [dependencies] 8 | anyhow = { workspace = true } 9 | serde = { workspace = true, features = ["derive"] } 10 | spin-sdk = { workspace = true } 11 | uuid = { version = "1.7.0", features = ["v4"] } 12 | -------------------------------------------------------------------------------- /cqrs-rust/crates/commands/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// API Model for creating a new Employee 4 | #[derive(Debug, Deserialize)] 5 | pub struct CreateEmployeeModel { 6 | /// Employee first name 7 | #[serde(rename = "firstName")] 8 | pub first_name: String, 9 | /// Employee last name 10 | #[serde(rename = "lastName")] 11 | pub last_name: String, 12 | /// Employee address 13 | pub address: CreateAddressModel, 14 | } 15 | 16 | /// API Model for creating a new address 17 | #[derive(Debug, Deserialize)] 18 | pub struct CreateAddressModel { 19 | /// street 20 | pub street: String, 21 | /// zip code 22 | pub zip: String, 23 | /// city 24 | pub city: String, 25 | } 26 | 27 | /// API Model for updating an Employee 28 | #[derive(Debug, Deserialize)] 29 | pub struct UpdateEmployeeModel { 30 | /// first name 31 | #[serde(rename = "firstName")] 32 | pub first_name: String, 33 | 34 | /// last name 35 | #[serde(rename = "lastName")] 36 | pub last_name: String, 37 | 38 | /// address 39 | pub address: UpdateAddressModel, 40 | } 41 | 42 | /// API Model for updating an Address 43 | #[derive(Debug, Deserialize)] 44 | pub struct UpdateAddressModel { 45 | /// street 46 | pub street: String, 47 | 48 | /// zip code 49 | pub zip: String, 50 | 51 | /// city 52 | pub city: String, 53 | } 54 | 55 | /// Response Model for a newly created Employee 56 | #[derive(Debug, Serialize)] 57 | pub struct EmployeeCreatedModel { 58 | /// unique identifier 59 | pub id: String, 60 | 61 | /// first name 62 | #[serde(rename = "firstName")] 63 | pub first_name: String, 64 | 65 | /// last name 66 | #[serde(rename = "lastName")] 67 | pub last_name: String, 68 | 69 | /// address 70 | pub address: AddressCreatedModel, 71 | } 72 | 73 | /// API model for a newly created address 74 | #[derive(Debug, Serialize)] 75 | pub struct AddressCreatedModel { 76 | /// identifier 77 | pub id: String, 78 | 79 | /// street 80 | pub street: String, 81 | 82 | /// zip code 83 | pub zip: String, 84 | 85 | /// city 86 | pub city: String, 87 | } 88 | 89 | /// API model for an updated employee 90 | #[derive(Debug, Serialize)] 91 | pub struct EmployeeUpdatedModel { 92 | /// identifier 93 | pub id: String, 94 | #[serde(rename = "firstName")] 95 | 96 | /// first name 97 | pub first_name: String, 98 | #[serde(rename = "lastName")] 99 | 100 | /// last name 101 | pub last_name: String, 102 | 103 | /// address 104 | pub address: AddressUpdatedModel, 105 | } 106 | 107 | /// API model for an updated Address 108 | #[derive(Debug, Serialize)] 109 | pub struct AddressUpdatedModel { 110 | /// identifier 111 | pub id: String, 112 | 113 | /// street 114 | pub street: String, 115 | 116 | /// zip code 117 | pub zip: String, 118 | 119 | /// city 120 | pub city: String, 121 | } 122 | -------------------------------------------------------------------------------- /cqrs-rust/crates/queries/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cqrs-queries" 3 | version = { workspace = true } 4 | authors = { workspace = true } 5 | edition = { workspace = true } 6 | 7 | [dependencies] 8 | anyhow = { workspace = true } 9 | serde = { workspace = true, features = ["derive"] } 10 | spin-sdk = { workspace = true } 11 | -------------------------------------------------------------------------------- /cqrs-rust/crates/queries/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | #[derive(Debug, Serialize)] 4 | pub struct EmployeeListModel { 5 | pub id: String, 6 | pub name: String, 7 | pub city: String, 8 | } 9 | 10 | #[derive(Debug, Serialize)] 11 | pub struct EmployeeDetailsModel { 12 | pub id: String, 13 | #[serde(rename = "firstName")] 14 | pub first_name: String, 15 | #[serde(rename = "lastName")] 16 | pub last_name: String, 17 | pub address: AddressDetailsModel, 18 | } 19 | 20 | #[derive(Debug, Serialize)] 21 | pub struct AddressDetailsModel { 22 | pub id: String, 23 | pub street: String, 24 | pub city: String, 25 | pub zip: String, 26 | } 27 | -------------------------------------------------------------------------------- /cqrs-rust/migrations.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=ON; 2 | 3 | CREATE TABLE IF NOT EXISTS Employees ( 4 | Id VARCHAR(36) NOT NULL, 5 | FirstName TEXT NOT NULL, 6 | LastName TEXT NOT NULL, 7 | PRIMARY KEY (Id) 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS Addresses ( 11 | EmployeeId VARCHAR(36) NOT NULL, 12 | Street VARCHAR(50) NOT NULL, 13 | Zip VARCHAR(10) NOT NULL, 14 | City VARCHAR(50) NOT NULL, 15 | FOREIGN KEY (EmployeeId) REFERENCES Employees (Id) 16 | ON DELETE CASCADE 17 | ); 18 | 19 | INSERT INTO Employees(Id, FirstName, LastName) 20 | SELECT '12a33c84-ee60-45a1-848d-428ad3259abc', 'John', 'Doe' 21 | WHERE 22 | NOT EXISTS ( 23 | SELECT Id FROM Employees WHERE Id = '12a33c84-ee60-45a1-848d-428ad3259abc'); 24 | 25 | INSERT INTO Addresses(EmployeeId, Street, Zip, City) 26 | SELECT '12a33c84-ee60-45a1-848d-428ad3259abc', '1234 Main Street', '02112', 'Boston' 27 | WHERE 28 | NOT EXISTS ( 29 | SELECT EmployeeId FROM Addresses WHERE EmployeeId = '12a33c84-ee60-45a1-848d-428ad3259abc'); -------------------------------------------------------------------------------- /cqrs-rust/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "cqrs-rust" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "A fairly simple CQRS implementation" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "cqrs-rust" 12 | 13 | [component.cqrs-rust] 14 | source = "target/wasm32-wasip1/release/cqrs_rust.wasm" 15 | allowed_outbound_hosts = [] 16 | sqlite_databases = ["default"] 17 | 18 | [component.cqrs-rust.build] 19 | command = "cargo build --target wasm32-wasip1 --release" 20 | watch = ["src/**/*.rs", "Cargo.toml"] 21 | -------------------------------------------------------------------------------- /cqrs-servicechaining/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cqrs-servicechaining/commands/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cqrs-servicechaining/commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "commands" 3 | authors = ["Thorsten Hans "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | serde = { version = "1.0.203", features = ["derive"] } 14 | serde_json = "1.0.117" 15 | spin-sdk = "3.0.1" 16 | uuid = { version = "1.8.0", features = ["v4"] } 17 | 18 | [workspace] 19 | -------------------------------------------------------------------------------- /cqrs-servicechaining/commands/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod models; 2 | mod persistence; 3 | use anyhow::Result; 4 | use models::{CreateEmployeeModel, UpdateEmployeeModel}; 5 | use spin_sdk::http::{IntoResponse, Params, Request, Response, ResponseBuilder, Router}; 6 | use spin_sdk::http_component; 7 | 8 | /// A simple Spin HTTP component. 9 | #[http_component] 10 | fn handle_commands(req: Request) -> anyhow::Result { 11 | let mut router = Router::default(); 12 | 13 | router.post("/create", create_employee); 14 | router.post("/update/:id", update_employee); 15 | router.post("/delete/:id", delete_employee); 16 | router.any("*", fallback); 17 | Ok(router.handle(req)) 18 | } 19 | 20 | fn fallback(req: Request, _: Params) -> Result { 21 | println!("{}:{}", req.method(), req.uri()); 22 | Ok(Response::new(404, ())) 23 | } 24 | fn create_employee(req: Request, _: Params) -> Result { 25 | let model: CreateEmployeeModel = serde_json::from_slice(req.body())?; 26 | 27 | let created = persistence::create_employee(model)?; 28 | let b = serde_json::to_vec(&created)?; 29 | Ok(ResponseBuilder::new(201) 30 | .header("Content-Type", "application/json") 31 | .body(b) 32 | .build()) 33 | } 34 | 35 | fn update_employee(req: Request, params: Params) -> Result { 36 | let model: UpdateEmployeeModel = serde_json::from_slice(req.body())?; 37 | 38 | let updated = match params.get("id") { 39 | Some(id) => persistence::update_employee_by_id(id, model)?, 40 | None => return Ok(Response::new(400, ())), 41 | }; 42 | match updated { 43 | Some(u) => { 44 | let b = serde_json::to_vec(&u)?; 45 | Ok(ResponseBuilder::new(200) 46 | .header("Content-Type", "application/json") 47 | .body(b) 48 | .build()) 49 | } 50 | None => Ok(Response::new(404, "Not Found")), 51 | } 52 | } 53 | 54 | fn delete_employee(_: Request, params: Params) -> Result { 55 | match params.get("id") { 56 | Some(id) => match persistence::delete_employee_by_id(id)? { 57 | true => Ok(Response::new(204, ())), 58 | false => Ok(Response::new(404, ())), 59 | }, 60 | None => Ok(Response::new(400, "Bad Request")), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cqrs-servicechaining/commands/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// API Model for creating a new Employee 4 | #[derive(Debug, Deserialize)] 5 | pub struct CreateEmployeeModel { 6 | /// Employee first name 7 | #[serde(rename = "firstName")] 8 | pub first_name: String, 9 | /// Employee last name 10 | #[serde(rename = "lastName")] 11 | pub last_name: String, 12 | /// Employee address 13 | pub address: CreateAddressModel, 14 | } 15 | 16 | /// API Model for creating a new address 17 | #[derive(Debug, Deserialize)] 18 | pub struct CreateAddressModel { 19 | /// street 20 | pub street: String, 21 | /// zip code 22 | pub zip: String, 23 | /// city 24 | pub city: String, 25 | } 26 | 27 | /// API Model for updating an Employee 28 | #[derive(Debug, Deserialize)] 29 | pub struct UpdateEmployeeModel { 30 | /// first name 31 | #[serde(rename = "firstName")] 32 | pub first_name: String, 33 | 34 | /// last name 35 | #[serde(rename = "lastName")] 36 | pub last_name: String, 37 | 38 | /// address 39 | pub address: UpdateAddressModel, 40 | } 41 | 42 | /// API Model for updating an Address 43 | #[derive(Debug, Deserialize)] 44 | pub struct UpdateAddressModel { 45 | /// street 46 | pub street: String, 47 | 48 | /// zip code 49 | pub zip: String, 50 | 51 | /// city 52 | pub city: String, 53 | } 54 | 55 | /// Response Model for a newly created Employee 56 | #[derive(Debug, Serialize)] 57 | pub struct EmployeeCreatedModel { 58 | /// unique identifier 59 | pub id: String, 60 | 61 | /// first name 62 | #[serde(rename = "firstName")] 63 | pub first_name: String, 64 | 65 | /// last name 66 | #[serde(rename = "lastName")] 67 | pub last_name: String, 68 | 69 | /// address 70 | pub address: AddressCreatedModel, 71 | } 72 | 73 | /// API model for a newly created address 74 | #[derive(Debug, Serialize)] 75 | pub struct AddressCreatedModel { 76 | /// identifier 77 | pub id: String, 78 | 79 | /// street 80 | pub street: String, 81 | 82 | /// zip code 83 | pub zip: String, 84 | 85 | /// city 86 | pub city: String, 87 | } 88 | 89 | /// API model for an updated employee 90 | #[derive(Debug, Serialize)] 91 | pub struct EmployeeUpdatedModel { 92 | /// identifier 93 | pub id: String, 94 | #[serde(rename = "firstName")] 95 | 96 | /// first name 97 | pub first_name: String, 98 | #[serde(rename = "lastName")] 99 | 100 | /// last name 101 | pub last_name: String, 102 | 103 | /// address 104 | pub address: AddressUpdatedModel, 105 | } 106 | 107 | /// API model for an updated Address 108 | #[derive(Debug, Serialize)] 109 | pub struct AddressUpdatedModel { 110 | /// identifier 111 | pub id: String, 112 | 113 | /// street 114 | pub street: String, 115 | 116 | /// zip code 117 | pub zip: String, 118 | 119 | /// city 120 | pub city: String, 121 | } 122 | -------------------------------------------------------------------------------- /cqrs-servicechaining/gateway/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cqrs-servicechaining/gateway/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gateway" 3 | authors = ["Thorsten Hans "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | spin-sdk = "3.0.1" 14 | 15 | [workspace] 16 | -------------------------------------------------------------------------------- /cqrs-servicechaining/migrations.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=ON; 2 | 3 | CREATE TABLE IF NOT EXISTS Employees ( 4 | Id VARCHAR(36) NOT NULL, 5 | FirstName TEXT NOT NULL, 6 | LastName TEXT NOT NULL, 7 | PRIMARY KEY (Id) 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS Addresses ( 11 | EmployeeId VARCHAR(36) NOT NULL, 12 | Street VARCHAR(50) NOT NULL, 13 | Zip VARCHAR(10) NOT NULL, 14 | City VARCHAR(50) NOT NULL, 15 | FOREIGN KEY (EmployeeId) REFERENCES Employees (Id) 16 | ON DELETE CASCADE 17 | ); 18 | 19 | INSERT INTO Employees(Id, FirstName, LastName) 20 | SELECT '12a33c84-ee60-45a1-848d-428ad3259abc', 'John', 'Doe' 21 | WHERE 22 | NOT EXISTS ( 23 | SELECT Id FROM Employees WHERE Id = '12a33c84-ee60-45a1-848d-428ad3259abc'); 24 | 25 | INSERT INTO Addresses(EmployeeId, Street, Zip, City) 26 | SELECT '12a33c84-ee60-45a1-848d-428ad3259abc', '1234 Main Street', '02112', 'Boston' 27 | WHERE 28 | NOT EXISTS ( 29 | SELECT EmployeeId FROM Addresses WHERE EmployeeId = '12a33c84-ee60-45a1-848d-428ad3259abc'); -------------------------------------------------------------------------------- /cqrs-servicechaining/queries/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /cqrs-servicechaining/queries/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/queries 2 | 3 | go 1.23 4 | 5 | require github.com/fermyon/spin/sdk/go/v2 v2.2.0 6 | 7 | require github.com/julienschmidt/httprouter v1.3.0 // indirect 8 | -------------------------------------------------------------------------------- /cqrs-servicechaining/queries/go.sum: -------------------------------------------------------------------------------- 1 | github.com/fermyon/spin/sdk/go/v2 v2.2.0 h1:zHZdIqjbUwyxiwdygHItnM+vUUNSZ3CX43jbIUemBI4= 2 | github.com/fermyon/spin/sdk/go/v2 v2.2.0/go.mod h1:kfJ+gdf/xIaKrsC6JHCUDYMv2Bzib1ohFIYUzvP+SCw= 3 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 4 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 5 | -------------------------------------------------------------------------------- /cqrs-servicechaining/queries/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 9 | "github.com/queries/pkg/persistence" 10 | ) 11 | 12 | func init() { 13 | spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { 14 | router := spinhttp.NewRouter() 15 | router.GET("/employees", getAllEmployees) 16 | router.GET("/employees/:id", getEmployeeById) 17 | router.ServeHTTP(w, r) 18 | }) 19 | } 20 | 21 | func getAllEmployees(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 22 | all, err := persistence.GetAllEmployees() 23 | if err != nil { 24 | http.Error(w, fmt.Sprintf("Error loading all employees: %v", err), 500) 25 | return 26 | } 27 | enc := json.NewEncoder(w) 28 | err = enc.Encode(all) 29 | if err != nil { 30 | http.Error(w, fmt.Sprintf("Error encoding all employees: %v", err), 500) 31 | return 32 | } 33 | w.Header().Set("Content-Type", "application/json") 34 | } 35 | 36 | func getEmployeeById(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 37 | id := params.ByName("id") 38 | if len(id) == 0 { 39 | http.Error(w, "Bad Request", 400) 40 | return 41 | } 42 | found, err := persistence.GetEmployeeById(id) 43 | if err != nil { 44 | http.Error(w, fmt.Sprintf("Error loading a specific employee: %v", err), 500) 45 | return 46 | } 47 | if found == nil { 48 | http.Error(w, "Not Found", 404) 49 | return 50 | } 51 | w.Header().Set("Content-Type", "application/json") 52 | enc := json.NewEncoder(w) 53 | err = enc.Encode(found) 54 | if err != nil { 55 | http.Error(w, fmt.Sprintf("Error encoding a specific employee: %v", err), 500) 56 | return 57 | } 58 | } 59 | func main() {} 60 | -------------------------------------------------------------------------------- /cqrs-servicechaining/queries/pkg/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type EmployeeListModel struct { 4 | Id string `json:"id"` 5 | Name string `json:"name"` 6 | City string `json:"city"` 7 | } 8 | 9 | type EmployeeDetailsModel struct { 10 | Id string `json:"id"` 11 | FirstName string `json:"firstName"` 12 | LastName string `json:"lastName"` 13 | Address AddressDetailsModel `json:"address"` 14 | } 15 | 16 | type AddressDetailsModel struct { 17 | Street string `json:"street"` 18 | Zip string `json:"zip"` 19 | City string `json:"city"` 20 | } 21 | 22 | func NewEmployeeDetailsModel() EmployeeDetailsModel { 23 | return EmployeeDetailsModel{ 24 | Address: AddressDetailsModel{}, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cqrs-servicechaining/queries/pkg/persistence/persistence.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "github.com/fermyon/spin/sdk/go/v2/sqlite" 5 | "github.com/queries/pkg/models" 6 | ) 7 | 8 | const ( 9 | db = "default" 10 | queryAllEmployees = "SELECT Employees.Id, Employees.LastName || ', ' || Employees.FirstName Name, Addresses.City FROM Employees INNER JOIN Addresses ON Employees.Id = Addresses.EmployeeId ORDER BY NAME ASC" 11 | queryEmployeeById = "SELECT Employees.Id, Employees.FirstName, Employees.LastName, Addresses.Street, Addresses.Zip, Addresses.City FROM Employees INNER JOIN Addresses ON Employees.Id = Addresses.EmployeeId WHERE Employees.Id = ?" 12 | ) 13 | 14 | func GetAllEmployees() ([]models.EmployeeListModel, error) { 15 | con := sqlite.Open(db) 16 | defer con.Close() 17 | 18 | rows, err := con.Query(queryAllEmployees) 19 | if err != nil { 20 | return nil, err 21 | } 22 | defer rows.Close() 23 | all := []models.EmployeeListModel{} 24 | for rows.Next() { 25 | var e models.EmployeeListModel 26 | err = rows.Scan(&e.Id, &e.Name, &e.City) 27 | if err != nil { 28 | return nil, err 29 | } 30 | all = append(all, e) 31 | } 32 | return all, nil 33 | } 34 | 35 | func GetEmployeeById(id string) (*models.EmployeeDetailsModel, error) { 36 | con := sqlite.Open(db) 37 | defer con.Close() 38 | 39 | rows, err := con.Query(queryEmployeeById, id) 40 | if err != nil { 41 | return nil, err 42 | } 43 | defer rows.Close() 44 | 45 | for rows.Next() { 46 | e := models.NewEmployeeDetailsModel() 47 | err = rows.Scan(&e.Id, &e.FirstName, &e.LastName, &e.Address.Street, &e.Address.Zip, &e.Address.City) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return &e, nil 53 | } 54 | 55 | return nil, nil 56 | } 57 | -------------------------------------------------------------------------------- /cqrs-servicechaining/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "cqrs-servicechaining" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "A fairly simple CQRS implementation using local service chaining capabilities of Spin" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "gateway" 12 | 13 | [component.gateway] 14 | source = "gateway/target/wasm32-wasi/release/gateway.wasm" 15 | allowed_outbound_hosts = ["https://*.spin.internal"] 16 | 17 | [component.gateway.build] 18 | command = "cargo build --target wasm32-wasip1 --release" 19 | workdir = "gateway" 20 | watch = ["src/**/*.rs", "Cargo.toml"] 21 | 22 | [[trigger.http]] 23 | route = { private = true } 24 | component = "queries" 25 | 26 | [component.queries] 27 | source = "queries/main.wasm" 28 | allowed_outbound_hosts = [] 29 | sqlite_databases = ["default"] 30 | 31 | [component.queries.build] 32 | command = "tinygo build -target=wasip1 -gc=leaking -buildmode=c-shared -no-debug -o main.wasm ." 33 | workdir = "queries" 34 | watch = ["**/*.go", "go.mod"] 35 | 36 | [[trigger.http]] 37 | route = { private = true } 38 | component = "commands" 39 | 40 | [component.commands] 41 | source = "commands/target/wasm32-wasi/release/commands.wasm" 42 | allowed_outbound_hosts = [] 43 | sqlite_databases = ["default"] 44 | 45 | [component.commands.build] 46 | command = "cargo build --target wasm32-wasip1 --release" 47 | workdir = "commands" 48 | watch = ["src/**/*.rs", "Cargo.toml"] 49 | -------------------------------------------------------------------------------- /distributed-todo-app/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /distributed-todo-app/distribute-apps.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -euo pipefail 4 | 5 | pushd src 6 | pushd stats-generator 7 | spin registry push ttl.sh/spin-todo-stats-generator:24h --build 8 | popd 9 | pushd migrations 10 | spin registry push ttl.sh/spin-todo-migrations:24h --build 11 | popd 12 | pushd http-api 13 | spin registry push ttl.sh/spin-todo-api:24h --build 14 | popd 15 | popd 16 | echo "Done" -------------------------------------------------------------------------------- /distributed-todo-app/kubernetes/api.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: core.spinoperator.dev/v1alpha1 2 | kind: SpinApp 3 | metadata: 4 | name: todo-api 5 | spec: 6 | image: "ttl.sh/spin-todo-api:24h" 7 | variables: 8 | - name: "db_host" 9 | value: "todo-db" 10 | - name: "db_connection_string" 11 | valueFrom: 12 | secretKeyRef: 13 | name: db-config 14 | key: connectionstring 15 | executor: containerd-shim-spin 16 | replicas: 2 17 | -------------------------------------------------------------------------------- /distributed-todo-app/kubernetes/db.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | db: todo 4 | user: timmy 5 | kind: ConfigMap 6 | metadata: 7 | name: db-config 8 | --- 9 | apiVersion: v1 10 | data: 11 | connectionstring: cG9zdGdyZXM6Ly90aW1teTpzZWNyZXRAdG9kby1kYi90b2Rv 12 | password: c2VjcmV0 13 | kind: Secret 14 | metadata: 15 | name: db-config 16 | --- 17 | apiVersion: apps/v1 18 | kind: Deployment 19 | metadata: 20 | name: postgres 21 | labels: 22 | app: postgres 23 | spec: 24 | replicas: 1 25 | selector: 26 | matchLabels: 27 | app: postgres 28 | template: 29 | metadata: 30 | labels: 31 | app: postgres 32 | spec: 33 | containers: 34 | - name: db 35 | image: postgres:latest 36 | ports: 37 | - containerPort: 5432 38 | env: 39 | - name: POSTGRES_USER 40 | valueFrom: 41 | configMapKeyRef: 42 | name: db-config 43 | key: user 44 | - name: POSTGRES_PASSWORD 45 | valueFrom: 46 | secretKeyRef: 47 | name: db-config 48 | key: password 49 | - name: POSTGRES_DB 50 | valueFrom: 51 | configMapKeyRef: 52 | name: db-config 53 | key: db 54 | resources: 55 | requests: 56 | cpu: 100m 57 | memory: 128Mi 58 | limits: 59 | cpu: 200m 60 | memory: 128Mi 61 | --- 62 | apiVersion: v1 63 | kind: Service 64 | metadata: 65 | name: todo-db 66 | spec: 67 | ports: 68 | - port: 5432 69 | targetPort: 5432 70 | selector: 71 | app: postgres 72 | -------------------------------------------------------------------------------- /distributed-todo-app/kubernetes/migrations.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: migrations 5 | spec: 6 | ttlSecondsAfterFinished: 600 7 | template: 8 | spec: 9 | runtimeClassName: wasmtime-spin-v2 10 | containers: 11 | - name: migrations 12 | image: ttl.sh/spin-todo-migrations:24h 13 | command: 14 | - / 15 | env: 16 | - name: "SPIN_VARIABLE_DB_HOST" 17 | value: "todo-db" 18 | - name: "SPIN_VARIABLE_DB_CONNECTION_STRING" 19 | valueFrom: 20 | secretKeyRef: 21 | name: db-config 22 | key: connectionstring 23 | restartPolicy: Never 24 | -------------------------------------------------------------------------------- /distributed-todo-app/kubernetes/stats-generator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: CronJob 3 | metadata: 4 | name: stats-generator 5 | spec: 6 | schedule: "*/2 * * * *" 7 | jobTemplate: 8 | spec: 9 | template: 10 | spec: 11 | runtimeClassName: wasmtime-spin-v2 12 | containers: 13 | - name: cron 14 | image: ttl.sh/spin-todo-stats-generator:24h 15 | command: 16 | - / 17 | env: 18 | - name: "SPIN_VARIABLE_DB_HOST" 19 | value: "todo-db" 20 | - name: "SPIN_VARIABLE_DB_CONNECTION_STRING" 21 | valueFrom: 22 | secretKeyRef: 23 | name: db-config 24 | key: connectionstring 25 | restartPolicy: OnFailure 26 | -------------------------------------------------------------------------------- /distributed-todo-app/run-cron-local.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # set flags 4 | set -euo pipefail 5 | 6 | pushd src 7 | ## Generate stats 8 | pushd stats-generator 9 | spin up --build 10 | popd 11 | -------------------------------------------------------------------------------- /distributed-todo-app/run-local.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | docker stop todo-db 2>/dev/null || true 4 | docker rm todo-db 2>/dev/null || true 5 | docker run --name todo-db -e POSTGRES_DB=todo -e POSTGRES_USER=timmy -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres 6 | sleep 2 7 | # set flags 8 | set -euo pipefail 9 | pushd src 10 | ## Initialize the database 11 | pushd migrations 12 | spin up --build 13 | popd 14 | 15 | ## Start the API 16 | pushd http-api 17 | spin up --build 18 | popd -------------------------------------------------------------------------------- /distributed-todo-app/src/http-api/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /distributed-todo-app/src/http-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "http-api" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1.0.92" 13 | serde = { version = "1.0.214", features = ["derive"] } 14 | serde_json = "1.0.132" 15 | spin-sdk = "3.0.1" 16 | uuid = { version = "1.11.0", features = ["serde", "v4"] } 17 | 18 | [workspace] 19 | -------------------------------------------------------------------------------- /distributed-todo-app/src/http-api/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "http-api" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | db_host = { default = "localhost" } 11 | db_connection_string = { default = "postgres://timmy:secret@localhost/todo" } 12 | 13 | [[trigger.http]] 14 | route = "/..." 15 | component = "http-api" 16 | 17 | [component.http-api] 18 | source = "target/wasm32-wasi/release/http_api.wasm" 19 | allowed_outbound_hosts = ["postgres://{{ db_host }}:5432"] 20 | 21 | [component.http-api.variables] 22 | connection_string = "{{ db_connection_string }}" 23 | 24 | [component.http-api.build] 25 | command = "cargo build --target wasm32-wasip1 --release" 26 | watch = ["src/**/*.rs", "Cargo.toml"] 27 | -------------------------------------------------------------------------------- /distributed-todo-app/src/http-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | use spin_sdk::http::{IntoResponse, Request, Router}; 2 | use spin_sdk::http_component; 3 | 4 | mod handlers; 5 | mod models; 6 | 7 | #[http_component] 8 | fn http_api(req: Request) -> anyhow::Result { 9 | let mut router = Router::default(); 10 | router.get("/tasks", handlers::get_all_tasks); 11 | router.post("/tasks", handlers::create_task); 12 | router.get("/tasks/:id", handlers::get_task_by_id); 13 | router.post("/tasks/toggle/:id", handlers::toggle_task_by_id); 14 | router.get("/stats", handlers::get_all_stats); 15 | Ok(router.handle(req)) 16 | } 17 | -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/enterprise-architectures-and-patterns/75818a626a6d91c630c95c524dbd1e60fd47eae6/distributed-todo-app/src/migrations/.DS_Store -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "migrations" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [package.metadata.component] 9 | package = "component:migrations" 10 | 11 | [package.metadata.component.dependencies] 12 | 13 | [dependencies] 14 | anyhow = "1.0.92" 15 | spin-sdk = "3.0.1" 16 | spin-executor = "3.0.1" 17 | wit-bindgen-rt = { version = "0.26.0", features = ["bitflags"] } 18 | rust-embed = "8.5.0" 19 | 20 | [workspace] 21 | -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/scripts/01-init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS Tasks ( 2 | id VARCHAR(36) PRIMARY KEY, 3 | content VARCHAR(255) NOT NULL, 4 | done BOOLEAN 5 | ); 6 | 7 | CREATE TABLE IF NOT EXISTS Stats ( 8 | timestamp VARCHAR DEFAULT TO_CHAR(CURRENT_TIMESTAMP, 'YYYY-MM-DD HH24:MI:SS'), 9 | open BIGINT DEFAULT 0, 10 | done BIGINT DEFAULT 0 11 | ); -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/scripts/02-sample-data.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM Tasks; 2 | 3 | INSERT INTO Tasks (id, content, done) 4 | VALUES 5 | ('bf0aeadf-115c-43de-8c38-da8f29d78b28', 'Buy groceries for the week', TRUE), 6 | ('fd539458-0240-4279-a9ba-53664c09bede', 'Finish reading that mystery novel', FALSE), 7 | ('4140d9b2-05c4-487b-b59e-a6ecb328b239', 'Clean the living room', TRUE), 8 | ('551b50ae-4ea1-4987-87d5-59d09cdbf6c6', 'Prepare presentation for Monday', FALSE), 9 | ('26f04c5b-3592-43c2-98bb-d85099a6aeac', 'Water the indoor plants', TRUE), 10 | ('a6e0b31f-e2b9-4f94-b340-5220b585e8dd', 'Call mom and check in', FALSE), 11 | ('8ba3615f-a4fd-4e07-8d78-8c3b29d23cb7', 'Schedule dentist appointment', TRUE), 12 | ('45d0e7e5-8e51-4560-b317-51b8ec9616d4', 'Plan weekend hiking trip', FALSE), 13 | ('5c9940d4-ec81-4389-b8ce-02cd476316d3', 'Organize workspace desk', TRUE), 14 | ('e4cef6e2-e296-4a17-8591-12b171e82a08', 'Submit project report', FALSE), 15 | ('ba0bc3fc-b4bc-4d23-9d3b-fa91545ca802', 'Go for a morning run', TRUE), 16 | ('5e5732db-1fbd-4eea-8b41-a9a24f1297fb', 'Catch up on latest tech news', FALSE), 17 | ('88ada386-b599-47ce-9601-be1d7ba8e412', 'Update resume and LinkedIn profile', TRUE), 18 | ('42c3111b-0e53-4117-9e4c-ab83eb2e044f', 'Bake cookies for neighbors', FALSE), 19 | ('ce724421-cfce-4922-ba34-9a7439b5727c', 'Fix leaky kitchen faucet', TRUE), 20 | ('ba6f77a2-73a7-4948-891a-73532b941cbf', 'Arrange books on the shelf', FALSE), 21 | ('28b70776-f1a2-4b16-b035-193f9c3990a1', 'Write a blog post on personal development', TRUE), 22 | ('8ae5dade-8e9c-4e55-bc89-e1dbc1acb220', 'Sort through old photos', FALSE), 23 | ('b97b3872-061e-4b8c-b1e2-56feee3a4f80', 'Send thank-you notes', TRUE), 24 | ('9b66862e-6e26-4c85-b4f4-2618ae84780c', 'Prepare healthy meal prep for the week', FALSE), 25 | ('aa69e57b-c532-4685-a66e-366343cc5a78', 'Research local volunteer opportunities', TRUE), 26 | ('4c636cd5-5232-4d33-9fd7-06dce2c5cd1c', 'Organize digital files and folders', FALSE), 27 | ('f8830240-2472-4a8c-9cd5-bc957e7435c8', 'Back up phone photos to cloud', TRUE), 28 | ('d206dd94-acf5-4544-b5a8-f43a092bb6a3', 'Review monthly budget and expenses', FALSE), 29 | ('9717f348-9c38-47e6-a963-9c9ec684312f', 'Practice meditation for 10 minutes', TRUE); 30 | -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "migrations" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | db_host = { default = "localhost" } 11 | db_connection_string = { default = "postgres://timmy:secret@localhost/todo" } 12 | 13 | [[trigger.command]] 14 | component = "migrations" 15 | 16 | [component.migrations] 17 | source = "target/wasm32-wasi/release/migrations.wasm" 18 | allowed_outbound_hosts = ["postgres://{{ db_host }}"] 19 | 20 | [component.migrations.variables] 21 | connection_string = "{{ db_connection_string }}" 22 | 23 | [component.migrations.build] 24 | command = "cargo component build --target wasm32-wasip1 --release" 25 | watch = ["src/**/*.rs", "Cargo.toml"] 26 | -------------------------------------------------------------------------------- /distributed-todo-app/src/migrations/src/main.rs: -------------------------------------------------------------------------------- 1 | use core::str; 2 | 3 | use anyhow::{Context, Result}; 4 | use rust_embed::Embed; 5 | use spin_sdk::{pg::Connection, variables}; 6 | 7 | fn get_connection() -> Result { 8 | let connection_string = variables::get("connection_string")?; 9 | Connection::open(&connection_string) 10 | .with_context(|| "Error establishing connection to PostgreSQL database") 11 | } 12 | 13 | fn main() -> Result<()> { 14 | println!("Migrating database"); 15 | let connection = get_connection()?; 16 | for file_path in Asset::iter() { 17 | let file = Asset::get(&file_path).unwrap(); 18 | let file_contents = str::from_utf8(&file.data)?; 19 | let statements = file_contents 20 | .split("\n\n") 21 | .filter(|s| !s.trim().is_empty()) 22 | .collect::>(); 23 | println!("Found {} statements in file", statements.len()); 24 | for statement in statements { 25 | println!("Executing: {}", statement); 26 | connection.execute(statement, &[])?; 27 | } 28 | } 29 | println!("Done."); 30 | Ok(()) 31 | } 32 | 33 | #[derive(Embed)] 34 | #[folder = "scripts"] 35 | #[include("*.sql")] 36 | struct Asset; 37 | -------------------------------------------------------------------------------- /distributed-todo-app/src/stats-generator/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /distributed-todo-app/src/stats-generator/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stats-generator" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [package.metadata.component] 9 | package = "component:stats-generator" 10 | 11 | [package.metadata.component.dependencies] 12 | 13 | [dependencies] 14 | anyhow = "1.0.92" 15 | spin-sdk = "3.0.1" 16 | spin-executor = "3.0.1" 17 | wit-bindgen-rt = { version = "0.34.0", features = ["bitflags"] } 18 | 19 | [workspace] 20 | -------------------------------------------------------------------------------- /distributed-todo-app/src/stats-generator/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "cron" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | db_host = { default = "localhost" } 11 | db_connection_string = { default = "postgres://timmy:secret@localhost/todo" } 12 | 13 | [[trigger.command]] 14 | component = "stats-generator" 15 | 16 | [component.stats-generator] 17 | source = "target/wasm32-wasi/release/stats-generator.wasm" 18 | allowed_outbound_hosts = ["postgres://{{ db_host }}:5432"] 19 | 20 | [component.stats-generator.variables] 21 | connection_string = "{{ db_connection_string }}" 22 | 23 | [component.stats-generator.build] 24 | command = "cargo component build --target wasm32-wasip1 --release" 25 | watch = ["src/**/*.rs", "Cargo.toml"] 26 | -------------------------------------------------------------------------------- /distributed-todo-app/src/stats-generator/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use spin_sdk::{ 3 | pg::{Connection, Decode, ParameterValue}, 4 | variables, 5 | }; 6 | 7 | const SQL_INSERT_STATS: &str = "INSERT INTO Stats (open, done) VALUES ($1, $2)"; 8 | const SQL_GET_TASK_STATS: &str = r#"SELECT done FROM Tasks"#; 9 | 10 | fn get_connection() -> Result { 11 | let connection_string = variables::get("connection_string")?; 12 | Connection::open(&connection_string) 13 | .with_context(|| "Error establishing connection to PostgreSQL database") 14 | } 15 | 16 | pub struct Stats { 17 | pub open: i64, 18 | pub done: i64, 19 | } 20 | 21 | fn main() -> Result<()> { 22 | println!("Generating stats"); 23 | let connection = get_connection()?; 24 | let row_set = connection.query(SQL_GET_TASK_STATS, &[])?; 25 | let stats = row_set 26 | .rows 27 | .iter() 28 | .fold(Stats { open: 0, done: 0 }, |mut acc, stat| { 29 | if bool::decode(&stat[0]).unwrap() { 30 | acc.done += 1; 31 | } else { 32 | acc.open += 1; 33 | } 34 | acc 35 | }); 36 | let parameters = [ 37 | ParameterValue::Int64(stats.open), 38 | ParameterValue::Int64(stats.done), 39 | ]; 40 | connection 41 | .execute(SQL_INSERT_STATS, ¶meters) 42 | .with_context(|| "Error while writing stats")?; 43 | println!("Done."); 44 | Ok(()) 45 | } 46 | -------------------------------------------------------------------------------- /distributed-tracing/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /distributed-tracing/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "distributed-tracing" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | spin-sdk = "3.0.0" 14 | 15 | [workspace] 16 | -------------------------------------------------------------------------------- /distributed-tracing/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: delete-container 2 | 3 | delete-container: 4 | @if docker ps -q -f name=jaeger | grep -q .; then \ 5 | echo "Stopping and removing container jaeger..."; \ 6 | docker stop jaeger; \ 7 | docker rm jaeger; \ 8 | else \ 9 | echo "Container jaeger does not exist or is not running."; \ 10 | fi 11 | 12 | .PHONY run: 13 | 14 | run: delete-container 15 | docker run -d -p 16686:16686 -p 4317:4317 -p 4318:4318 -e COLLECTOR_OTLP_ENABLED=true --name jaeger jaegertracing/all-in-one:latest;\ 16 | export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318;\ 17 | spin up --build 18 | -------------------------------------------------------------------------------- /distributed-tracing/README.md: -------------------------------------------------------------------------------- 1 | # Distributed Tracing with Spin and Jaeger 2 | 3 | This sample illustrates Spin native integration with OpenTelemetry (OTel). Spin automatically instruments your applications. If the `OTEL_EXPORTER_OTLP_ENDPOINT` is present, the Spin runtime will automatically send distributed traces to the OTLP endpoint. 4 | 5 | This sample uses [Jaeger](https://www.jaegertracing.io/) for visualizing distributed traces collected by Spin. 6 | 7 | ## API Endpoints 8 | 9 | The Spin App exposes the following endpoints: 10 | 11 | - `GET /` -> Returns an HTTP 200 with a response body 12 | - `GET /slow` -> Sleeps for 5 seconds before returning an HTTP 200 13 | - `GET /kv` -> Interacts with a key-value store before returning an HTTP 200 14 | - `GET /400` -> Returns an HTTP 400 15 | - `GET /404` -> Returns an HTTP 404 16 | - `GET /500` -> Returns an HTTP 500 17 | 18 | ## Supported Platforms 19 | 20 | - Local (`spin up`) 21 | - SpinKube 22 | - Fermyon Platform for Kubernetes 23 | 24 | ## Prerequisites 25 | 26 | To use this sample you must have 27 | 28 | - [Rust](https://www.rust-lang.org/) installed on your machine 29 | - The `wasm32-wasi` target for Rust installed (`rustup target add wasm32-wasi`) 30 | - [Spin](https://developer.fermyon.com/spin/v2/index) CLI installed on your machine 31 | - [Docker](https://docker.com) or an alternative container runtime is required to run Jaeger locally 32 | 33 | ## Running the Sample 34 | 35 | ### Local (`spin up`) 36 | 37 | To run the sample locally, you can use the `run` target defined by the [`Makefile`](./Makefile). 38 | 39 | The `run` target does the following: 40 | 41 | - If a `jaeger` container is running on your machine, it will be stopped and deleted 42 | - Jaeger All-In-One will be started locally 43 | - Necessary `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable will be set 44 | - The Spin App will be started using `spin up --build` 45 | -------------------------------------------------------------------------------- /distributed-tracing/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "distributed-tracing" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "distributed-tracing" 12 | 13 | [component.distributed-tracing] 14 | source = "target/wasm32-wasi/release/distributed_tracing.wasm" 15 | environment = { OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318" } 16 | allowed_outbound_hosts = [] 17 | key_value_stores = ["default"] 18 | 19 | [component.distributed-tracing.build] 20 | command = "cargo build --target wasm32-wasip1 --release" 21 | watch = ["src/**/*.rs", "Cargo.toml"] 22 | -------------------------------------------------------------------------------- /distributed-tracing/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::thread::sleep; 2 | use std::time::Duration; 3 | 4 | use anyhow::Result; 5 | use spin_sdk::http::{IntoResponse, Params, Request, Response, ResponseBuilder, Router}; 6 | use spin_sdk::http_component; 7 | use spin_sdk::key_value::Store; 8 | #[http_component] 9 | fn api(req: Request) -> Result { 10 | let mut router = Router::default(); 11 | 12 | router.get("/", regular_endpoint); 13 | router.get("/slow", slow_endpoint); 14 | router.get("/kv", kv_endpoint); 15 | router.get("/400", bad_request_endpoint); 16 | router.get("/404", not_found_endpoint); 17 | router.get("/500", internal_server_errror_endpoint); 18 | Ok(router.handle(req)) 19 | } 20 | 21 | fn bad_request_endpoint(_: Request, _: Params) -> Result { 22 | Ok(Response::new(400, ())) 23 | } 24 | 25 | fn not_found_endpoint(_: Request, _: Params) -> Result { 26 | Ok(Response::new(404, ())) 27 | } 28 | 29 | fn internal_server_errror_endpoint(_: Request, _: Params) -> Result { 30 | Ok(Response::new(500, ())) 31 | } 32 | 33 | fn regular_endpoint(_: Request, _: Params) -> Result { 34 | Ok(Response::new(200, ())) 35 | } 36 | 37 | fn slow_endpoint(_: Request, _: Params) -> Result { 38 | sleep(Duration::from_secs(5)); 39 | Ok(Response::new(200, ())) 40 | } 41 | 42 | fn kv_endpoint(_: Request, _: Params) -> Result { 43 | let store = Store::open_default()?; 44 | 45 | let value = match store.get("foo")? { 46 | Some(v) => { 47 | let bytes_array: [u8; 4] = v.try_into().expect("Vec must be exactly 4 bytes long"); 48 | i32::from_le_bytes(bytes_array) 49 | } 50 | None => { 51 | let value = 1_i32; 52 | store.set("foo", &value.to_le_bytes())?; 53 | value 54 | } 55 | }; 56 | Ok(ResponseBuilder::new(200) 57 | .header("Content-Type", "text/plain") 58 | .body(format!("{value}")) 59 | .build()) 60 | } 61 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/README.md: -------------------------------------------------------------------------------- 1 | ## HTTP CRUD Sample 2 | 3 | This is a sample implementation of CRUD (Create, Read, Update, Delete) in Go. 4 | 5 | The sample is using SQLite for persistence and provides the following API endpoints via HTTP: 6 | 7 | - `GET /items` - To retrieve a list of all items 8 | - `GET /items/:id` - To retrieve a item using its identifier 9 | - `POST /items` - To create a new item 10 | - `PUT /items/:id` - To update an existing item using its identifier 11 | - `DELETE /items/batch` - To delete multiple items providing an array of identifiers as payload 12 | - `DELETE /items/:id` - To delete an existing item using its identifier 13 | 14 | Send data to `POST /items` and `PUT /items/:id` using the following structure: 15 | 16 | ```jsonc 17 | { 18 | "name": "item name", 19 | // boolean (either true or false) 20 | "active": true 21 | } 22 | ``` 23 | 24 | ## Prerequisites 25 | 26 | To run the sample on your local machine, you must have the following software installed: 27 | 28 | - Latest [Spin](https://developer.fermyon.com/spin) CLI 29 | - [TinyGo](https://tinygo.org/) 30 | 31 | ## Running this Sample 32 | 33 | ### Local (`spin up`) 34 | 35 | To run the sample locally, you must provide `@migrations.sql` using the `--sqlite` flag to seed the database as shown in the snippet below: 36 | 37 | ```bash 38 | # Build the project 39 | spin build 40 | 41 | # Run the sample 42 | spin up --sqlite @migrations.sql 43 | Logging component stdio to ".spin/logs/" 44 | Storing default SQLite data to ".spin/sqlite_db.db" 45 | 46 | Serving http://127.0.0.1:3000 47 | Available Routes: 48 | http-crud-go-sqlite: http://127.0.0.1:3000 (wildcard) 49 | ``` 50 | 51 | ### Fermyon Cloud 52 | 53 | You can deploy this sample to Fermyon Cloud following the steps below: 54 | 55 | ```bash 56 | # Authenticate 57 | spin cloud login 58 | 59 | # Deploy the sample to Fermyon Cloud 60 | # This will ask if a new database should be created or an existing one should be used 61 | # Answer the question with "create a new database" 62 | spin deploy 63 | Uploading http-crud-go-sqlite version 0.1.0 to Fermyon Cloud... 64 | Deploying... 65 | App "http-crud-go-sqlite" accesses a database labeled "default" 66 | Would you like to link an existing database or create a new database?: Create a new database and link the app to it 67 | What would you like to name your database? 68 | What would you like to name your database? 69 | Note: This name is used when managing your database at the account level. The app "http-crud-go-sqlite" will refer to this database by the label "default". 70 | Other apps can use different labels to refer to the same database.: sincere-mulberry 71 | Creating database named 'sincere-mulberry' 72 | Waiting for application to become ready.......... ready 73 | 74 | View application: https://http-crud-go-sqlite-jcmbpezb.fermyon.app/ 75 | Manage application: https://cloud.fermyon.com/app/http-crud-go-sqlite 76 | 77 | # Ensure tables are created in the new database (here sincere-mulberry) 78 | spin cloud sqlite execute --database sincere-mulberry @migrations.sql 79 | ``` -------------------------------------------------------------------------------- /http-crud-go-sqlite/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fermyon/enterprise-architectures-and-patterns/http-crud-go-sqlite 2 | 3 | go 1.23 4 | 5 | require github.com/fermyon/spin/sdk/go/v2 v2.2.0 6 | 7 | require ( 8 | github.com/google/uuid v1.6.0 // indirect 9 | github.com/jmoiron/sqlx v1.3.5 // indirect 10 | github.com/julienschmidt/httprouter v1.3.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/go.sum: -------------------------------------------------------------------------------- 1 | github.com/fermyon/spin/sdk/go/v2 v2.2.0 h1:zHZdIqjbUwyxiwdygHItnM+vUUNSZ3CX43jbIUemBI4= 2 | github.com/fermyon/spin/sdk/go/v2 v2.2.0/go.mod h1:kfJ+gdf/xIaKrsC6JHCUDYMv2Bzib1ohFIYUzvP+SCw= 3 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 4 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 5 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 6 | github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= 7 | github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 8 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 9 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 10 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 11 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 12 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/fermyon/enterprise-architectures-and-patterns/http-crud-go-sqlite/pkg/api" 7 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 8 | ) 9 | 10 | func init() { 11 | spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) { 12 | router := api.New() 13 | router.ServeHTTP(w, r) 14 | }) 15 | } 16 | 17 | func main() {} 18 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS ITEMS (ID VARCHAR(36) PRIMARY KEY, NAME TEXT NOT NULL, ACTIVE INTEGER); 2 | 3 | INSERT INTO ITEMS(ID, NAME, ACTIVE) 4 | SELECT '8b933c84-ee60-45a1-848d-428ad3259e2b', 'Full Self Driving (FSD)', 1 5 | WHERE 6 | NOT EXISTS ( 7 | SELECT ID FROM ITEMS WHERE ID = '8b933c84-ee60-45a1-848d-428ad3259e2b' 8 | ); 9 | 10 | INSERT INTO ITEMS(ID, NAME, ACTIVE) 11 | SELECT 'd660b9b2-0406-46d6-9efe-b40b4cca59fc', 'Sentry Mode', 1 12 | WHERE 13 | NOT EXISTS ( 14 | SELECT ID FROM ITEMS WHERE ID = 'd660b9b2-0406-46d6-9efe-b40b4cca59fc' 15 | ); -------------------------------------------------------------------------------- /http-crud-go-sqlite/pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 8 | ) 9 | 10 | func New() *spinhttp.Router { 11 | r := spinhttp.NewRouter() 12 | r.GET("/items", getAllItems) 13 | r.GET("/items/:id", getItemById) 14 | r.POST("/items", createItem) 15 | r.PUT("/items/:id", updateItemById) 16 | r.DELETE("/items/:id", deleteItemById) 17 | r.DELETE("/items", deleteMultipleItems) 18 | return r 19 | } 20 | 21 | func sendAsJson(w http.ResponseWriter, data interface{}) { 22 | header := w.Header() 23 | header.Set("Content-Type", "application/json") 24 | 25 | encoder := json.NewEncoder(w) 26 | encoder.SetIndent("", " ") 27 | encoder.Encode(data) 28 | } 29 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/pkg/api/handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/fermyon/enterprise-architectures-and-patterns/http-crud-go-sqlite/pkg/persistence" 8 | "github.com/fermyon/enterprise-architectures-and-patterns/http-crud-go-sqlite/pkg/types" 9 | spinhttp "github.com/fermyon/spin/sdk/go/v2/http" 10 | ) 11 | 12 | func getAllItems(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 13 | items, err := persistence.ReadAllItems(r.Context()) 14 | if err != nil { 15 | http.Error(w, err.Error(), http.StatusInternalServerError) 16 | return 17 | } 18 | sendAsJson(w, items) 19 | } 20 | 21 | func getItemById(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 22 | item, err := persistence.ReadItemById(r.Context(), params.ByName("id")) 23 | if err != nil { 24 | http.Error(w, err.Error(), http.StatusInternalServerError) 25 | return 26 | } 27 | if item == nil { 28 | http.NotFound(w, r) 29 | return 30 | } 31 | sendAsJson(w, item) 32 | } 33 | 34 | func createItem(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 35 | var model types.ItemCreateModel 36 | decoder := json.NewDecoder(r.Body) 37 | err := decoder.Decode(&model) 38 | if err != nil { 39 | http.Error(w, err.Error(), http.StatusBadRequest) 40 | return 41 | } 42 | item, err := persistence.CreateItem(r.Context(), model) 43 | if err != nil { 44 | http.Error(w, err.Error(), http.StatusInternalServerError) 45 | return 46 | } 47 | sendAsJson(w, item) 48 | 49 | } 50 | 51 | func updateItemById(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 52 | var model types.ItemUpdateModel 53 | decoder := json.NewDecoder(r.Body) 54 | err := decoder.Decode(&model) 55 | if err != nil { 56 | http.Error(w, err.Error(), http.StatusBadRequest) 57 | return 58 | } 59 | item, err := persistence.UpdateItemById(r.Context(), params.ByName("id"), model) 60 | if err != nil { 61 | http.Error(w, err.Error(), http.StatusInternalServerError) 62 | return 63 | } 64 | if item == nil { 65 | http.NotFound(w, r) 66 | return 67 | } 68 | sendAsJson(w, item) 69 | } 70 | 71 | func deleteMultipleItems(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 72 | var model types.BatchDeleteModel 73 | decoder := json.NewDecoder(r.Body) 74 | err := decoder.Decode(&model) 75 | if err != nil { 76 | http.Error(w, err.Error(), http.StatusBadRequest) 77 | return 78 | } 79 | err = persistence.DeleteMultipleItems(r.Context(), model) 80 | if err != nil { 81 | http.Error(w, err.Error(), http.StatusInternalServerError) 82 | return 83 | } 84 | w.WriteHeader(http.StatusNoContent) 85 | } 86 | 87 | func deleteItemById(w http.ResponseWriter, r *http.Request, params spinhttp.Params) { 88 | err := persistence.DeleteItemById(r.Context(), params.ByName("id")) 89 | if err != nil { 90 | http.Error(w, err.Error(), http.StatusInternalServerError) 91 | return 92 | } 93 | w.WriteHeader(http.StatusNoContent) 94 | } 95 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Item struct { 4 | Id string `json:"id" db:"ID"` 5 | Name string `json:"name" db:"NAME"` 6 | Active bool `json:"active" db:"ACTIVE"` 7 | } 8 | 9 | type ItemCreateModel struct { 10 | Name string `json:"name"` 11 | Active bool `json:"active"` 12 | } 13 | 14 | func (model *ItemCreateModel) NewItemWithId(id string) Item { 15 | return Item{ 16 | Id: id, 17 | Name: model.Name, 18 | Active: model.Active, 19 | } 20 | } 21 | 22 | type ItemUpdateModel struct { 23 | Name string `json:"name"` 24 | Active bool `json:"active"` 25 | } 26 | 27 | type BatchDeleteModel struct { 28 | Ids []string `json:"ids"` 29 | } 30 | 31 | func (model *ItemUpdateModel) AsItem(id string) Item { 32 | return Item{ 33 | Id: id, 34 | Name: model.Name, 35 | Active: model.Active, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /http-crud-go-sqlite/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "http-crud-go-sqlite" 5 | version = "0.1.0" 6 | authors = ["Thorsten Hans "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "http-crud-go-sqlite" 12 | 13 | [component.http-crud-go-sqlite] 14 | source = "main.wasm" 15 | sqlite_databases = ["default"] 16 | allowed_outbound_hosts = [] 17 | 18 | [component.http-crud-go-sqlite.build] 19 | command = "tinygo build -target=wasip1 -gc=leaking -buildmode=c-shared -no-debug -o main.wasm ." 20 | watch = ["**/*.go", "go.mod"] 21 | -------------------------------------------------------------------------------- /http-crud-js-pg/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | target 4 | .spin/ 5 | build/ -------------------------------------------------------------------------------- /http-crud-js-pg/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: database-up 2 | database-up: 3 | docker run --name spin-crud-js-db -d -e POSTGRES_DB=sample -e POSTGRES_USER=timmy -e POSTGRES_PASSWORD=secret -p 5432:5432 spin-crud-js-db:local 4 | 5 | .PHONY: datbase-down 6 | database-down: 7 | docker rm -f spin-crud-js-db 8 | 9 | .PHONY: database-build 10 | database-build: 11 | docker build -f postgres.Dockerfile -t spin-crud-js-db:local . 12 | 13 | .PHONY: build 14 | build: 15 | spin build 16 | 17 | .PHONY: run 18 | run: 19 | SPIN_VARIABLE_DB_CONNECTION_STRING=postgres://timmy:secret@localhost/sample spin up 20 | -------------------------------------------------------------------------------- /http-crud-js-pg/README.md: -------------------------------------------------------------------------------- 1 | ## HTTP CRUD Sample 2 | 3 | This is a sample implementation of CRUD (Create, Read, Update, Delete) in JavaScript. 4 | 5 | The sample is using PostgreSQL for persistence and provides the following API endpoints via HTTP: 6 | 7 | - `GET /items` - To retrieve a list of all items 8 | - `GET /items/:id` - To retrieve a item using its identifier 9 | - `POST /items` - To create a new item 10 | - `PUT /items/:id` - To update an existing item using its identifier 11 | - `DELETE /items` - To delete multiple items providing an array of identifiers as payload (`{ "ids": []}`) 12 | - `DELETE /items/:id` - To delete an existing item using its identifier 13 | 14 | Send data to `POST /items` and `PUT /items/:id` using the following structure: 15 | 16 | ```jsonc 17 | { 18 | "name": "item name", 19 | // boolean (either true or false) 20 | "active": true 21 | } 22 | ``` 23 | 24 | ## Supported Platforms 25 | 26 | - Local (`spin up`) 27 | - SpinKube 28 | - Fermyon Platform for Kubernetes 29 | - 30 | ## Prerequisites 31 | 32 | To run the sample on your local machine, you must have the following software installed: 33 | 34 | - Latest [Spin](https://developer.fermyon.com/spin) CLI 35 | - [Docker](https://docker.com) 36 | - [Node.js](https://nodejs.org) 37 | 38 | 39 | ## Running the Sample 40 | 41 | ### Local (`spin up`) 42 | 43 | To run this sample locally, you can either follow the steps mentioned below or use the corresponding targets specified in the `Makefile`. 44 | 45 | 1. Build the container image for the database using `docker build -f postgres.Dockerfile -t spin-crud-js-db:local .` 46 | 2. Run the database container using `docker run --name spin-crud-js-db -d -e POSTGRES_DB=sample -e POSTGRES_USER=timmy -e POSTGRES_PASSWORD=secret -p 5432:5432 spin-crud-js-db:local` 47 | 3. Build the Spin App using `spin build` 48 | 4. Run the Spin App using `SPIN_VARIABLE_DB_CONNECTION_STRING=postgres://timmy:secret@localhost/sample spin up` 49 | 50 | At this point, you can invoke the API endpoints mentioned above at http://localhost:3000/ 51 | -------------------------------------------------------------------------------- /http-crud-js-pg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crud-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx webpack && mkdirp dist && j2w -i build/bundle.js -o dist/crud-api.wasm", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "mkdirp": "^3.0.1", 15 | "webpack": "^5.74.0", 16 | "webpack-cli": "^4.10.0" 17 | }, 18 | "dependencies": { 19 | "@spinframework/build-tools": "^1.0.1", 20 | "@spinframework/spin-postgres": "^1.0.0", 21 | "@spinframework/spin-variables": "^1.0.0", 22 | "@spinframework/wasi-http-proxy": "^1.0.0", 23 | "itty-router": "^5.0.18", 24 | "uuid": "^11.1.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /http-crud-js-pg/scripts/pg/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | psql -U $POSTGRES_USER -d $POSTGRES_DB -a -f /app/scripts/db/init.sql 4 | -------------------------------------------------------------------------------- /http-crud-js-pg/scripts/pg/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS Items ( 2 | Id varchar(36) PRIMARY KEY, 3 | Name TEXT NOT NULL, 4 | Active BOOLEAN NOT NULL 5 | ); 6 | 7 | INSERT INTO 8 | Items (Id, Name, Active) 9 | SELECT 10 | '9ccc555f-e4f8-446c-83cb-ee1542862450', 11 | 'Lane Assistant', 12 | TRUE 13 | WHERE 14 | NOT EXISTS ( 15 | SELECT 16 | Id 17 | FROM 18 | Items 19 | WHERE 20 | Id = '9ccc555f-e4f8-446c-83cb-ee1542862450' 21 | ); 22 | 23 | INSERT INTO 24 | Items (Id, Name, Active) 25 | SELECT 26 | '35499593-ff71-4b13-8bd5-07ddf47bee6b', 27 | 'Sentry Mode', 28 | TRUE 29 | WHERE 30 | NOT EXISTS ( 31 | SELECT 32 | Id 33 | FROM 34 | Items 35 | WHERE 36 | Id = '35499593-ff71-4b13-8bd5-07ddf47bee6b' 37 | ); 38 | 39 | INSERT INTO 40 | Items (Id, Name, Active) 41 | SELECT 42 | 'eb74da9d-3bb5-4f4f-825a-9bb628f5691b', 43 | 'Autopilot', 44 | FALSE 45 | WHERE 46 | NOT EXISTS ( 47 | SELECT 48 | Id 49 | FROM 50 | Items 51 | WHERE 52 | Id = 'eb74da9d-3bb5-4f4f-825a-9bb628f5691b' 53 | ); 54 | -------------------------------------------------------------------------------- /http-crud-js-pg/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | authors = ["Fermyon Engineering "] 5 | description = "" 6 | name = "http-crud-js" 7 | version = "0.1.0" 8 | 9 | [variables] 10 | db_connection_string = { required = true } 11 | 12 | [[trigger.http]] 13 | route = "/..." 14 | component = "crud-api" 15 | 16 | [component.crud-api] 17 | source = "dist/crud-api.wasm" 18 | exclude_files = ["**/node_modules"] 19 | allowed_outbound_hosts = ["postgres://localhost"] 20 | 21 | [component.crud-api.variables] 22 | db_connection_string = "{{ db_connection_string }}" 23 | 24 | [component.crud-api.build] 25 | command = ["npm install", "npm run build"] 26 | watch = ["src/**/*.js"] 27 | -------------------------------------------------------------------------------- /http-crud-js-pg/src/config.js: -------------------------------------------------------------------------------- 1 | import * as Variables from "@spinframework/spin-variables"; 2 | 3 | const withConfig = (request) => { 4 | request.config = { 5 | dbConnectionString: Variables.get("db_connection_string"), 6 | }; 7 | }; 8 | 9 | export { withConfig }; 10 | -------------------------------------------------------------------------------- /http-crud-js-pg/src/index.js: -------------------------------------------------------------------------------- 1 | // For AutoRouter documentation refer to https://itty.dev/itty-router/routers/autorouter 2 | import { AutoRouter } from "itty-router"; 3 | import { withConfig } from "./config"; 4 | import { 5 | deleteItemById, 6 | deleteManyItems, 7 | getAllItems, 8 | getItemById, 9 | createItem, 10 | updateItemById, 11 | notFound, 12 | } from "./handlers"; 13 | 14 | let router = AutoRouter(); 15 | router.all("*", withConfig); 16 | router.get("/items", ({ config }) => getAllItems(config)); 17 | router.get("/items/:id", ({ params, config }) => 18 | getItemById(config, params.id), 19 | ); 20 | router.post("/items", async (req, { baseUrl }) => 21 | createItem(req.config, baseUrl, await req.arrayBuffer()), 22 | ); 23 | router.put("/items/:id", async (req, { baseUrl }) => 24 | updateItemById(req.config, baseUrl, req.params.id, await req.arrayBuffer()), 25 | ); 26 | router.delete("/items", async (req) => 27 | deleteManyItems(req.config, await req.arrayBuffer()), 28 | ); 29 | router.delete("/items/:id", (req) => deleteItemById(req.config, req.params.id)); 30 | router.all("*", () => notFound("Endpoint not found")); 31 | 32 | addEventListener("fetch", (event) => { 33 | const fullUrl = event.request.headers.get("spin-full-url"); 34 | const path = event.request.headers.get("spin-path-info"); 35 | const baseUrl = fullUrl.substr(0, fullUrl.indexOf(path)); 36 | 37 | event.respondWith( 38 | router.fetch(event.request, { 39 | baseUrl, 40 | fullUrl, 41 | path, 42 | }), 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /http-crud-js-pg/webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import SpinSdkPlugin from "@spinframework/build-tools/plugins/webpack/index.js"; 3 | 4 | const config = async () => { 5 | let SpinPlugin = await SpinSdkPlugin.init() 6 | return { 7 | mode: 'production', 8 | stats: 'errors-only', 9 | entry: './src/index.js', 10 | experiments: { 11 | outputModule: true, 12 | }, 13 | resolve: { 14 | extensions: ['.js'], 15 | }, 16 | output: { 17 | path: path.resolve(process.cwd(), './build'), 18 | filename: 'bundle.js', 19 | module: true, 20 | library: { 21 | type: "module", 22 | } 23 | }, 24 | plugins: [ 25 | SpinPlugin 26 | ], 27 | optimization: { 28 | minimize: false 29 | }, 30 | }; 31 | } 32 | export default config -------------------------------------------------------------------------------- /http-crud-js-sqlite/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | target 4 | .spin/ 5 | build/ -------------------------------------------------------------------------------- /http-crud-js-sqlite/README.md: -------------------------------------------------------------------------------- 1 | ## HTTP CRUD Sample 2 | 3 | This is a sample implementation of CRUD (Create, Read, Update, Delete) in JavaScript. 4 | 5 | The sample is using SQLite for persistence and provides the following API endpoints via HTTP: 6 | 7 | - `GET /items` - To retrieve a list of all items 8 | - `GET /items/:id` - To retrieve a item using its identifier 9 | - `POST /items` - To create a new item 10 | - `PUT /items/:id` - To update an existing item using its identifier 11 | - `DELETE /items` - To delete multiple items providing an array of identifiers as payload (`{ "ids": []}`) 12 | - `DELETE /items/:id` - To delete an existing item using its identifier 13 | 14 | Send data to `POST /items` and `PUT /items/:id` using the following structure: 15 | 16 | ```jsonc 17 | { 18 | "name": "item name", 19 | // boolean (either true or false) 20 | "active": true 21 | } 22 | ``` 23 | 24 | ## Prerequisites 25 | 26 | To run the sample on your local machine, you must have the following software installed: 27 | 28 | - Latest [Spin](https://developer.fermyon.com/spin) CLI 29 | - [Node.js](https://nodejs.org) 30 | 31 | ## Running this Sample 32 | 33 | ### Local (`spin up`) 34 | 35 | To run the sample locally, you must provide the `migrations.sql` file using the `--sqlite` flag to provision the database on the first run: 36 | 37 | ```bash 38 | # Build the project 39 | spin build 40 | 41 | # Run the sample 42 | spin up --sqlite @migrations.sql 43 | Logging component stdio to ".spin/logs/" 44 | Storing default SQLite data to ".spin/sqlite_db.db" 45 | 46 | Serving http://127.0.0.1:3000 47 | Available Routes: 48 | crud-api: http://127.0.0.1:3000 (wildcard) 49 | ``` 50 | 51 | ### Fermyon Cloud 52 | 53 | You can deploy this sample to Fermyon Cloud following the steps below: 54 | 55 | ```bash 56 | # Authenticate 57 | spin cloud login 58 | 59 | # Deploy the sample to Fermyon Cloud 60 | # This will ask if a new database should be created or an existing one should be used 61 | # Answer the question with "create a new database" 62 | spin deploy 63 | Uploading http-crud-js-sqlite version 0.1.0 to Fermyon Cloud... 64 | Deploying... 65 | App "http-crud-js-sqlite" accesses a database labeled "crud" 66 | Would you like to link an existing database or create a new database?: Create a new database and link the app to it 67 | What would you like to name your database? 68 | What would you like to name your database? 69 | Note: This name is used when managing your database at the account level. The app "http-crud-js-sqlite" will refer to this database by the label "default". 70 | Other apps can use different labels to refer to the same database.: sincere-mulberry 71 | Creating database named 'sincere-mulberry' 72 | Waiting for application to become ready.......... ready 73 | 74 | View application: https://http-crud-js-sqlite-jcmbpezb.fermyon.app/ 75 | Manage application: https://cloud.fermyon.com/app/http-crud-js-sqlite 76 | 77 | # Ensure tables are created in the new database (here sincere-mulberry) 78 | spin cloud sqlite execute --database sincere-mulberry @migrations.sql 79 | ``` 80 | -------------------------------------------------------------------------------- /http-crud-js-sqlite/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS ITEMS ( 2 | ID VARCHAR(36) PRIMARY KEY, 3 | NAME TEXT NOT NULL, 4 | ACTIVE INTEGER 5 | ); 6 | 7 | INSERT INTO 8 | ITEMS (ID, NAME, ACTIVE) 9 | SELECT 10 | '8b933c84-ee60-45a1-848d-428ad3259e2b', 11 | 'Full Self Driving (FSD)', 12 | 1 13 | WHERE 14 | NOT EXISTS ( 15 | SELECT 16 | ID 17 | FROM 18 | ITEMS 19 | WHERE 20 | ID = '8b933c84-ee60-45a1-848d-428ad3259e2b' 21 | ); 22 | 23 | INSERT INTO 24 | ITEMS (ID, NAME, ACTIVE) 25 | SELECT 26 | 'd660b9b2-0406-46d6-9efe-b40b4cca59fc', 27 | 'Sentry Mode', 28 | 1 29 | WHERE 30 | NOT EXISTS ( 31 | SELECT 32 | ID 33 | FROM 34 | ITEMS 35 | WHERE 36 | ID = 'd660b9b2-0406-46d6-9efe-b40b4cca59fc' 37 | ); 38 | -------------------------------------------------------------------------------- /http-crud-js-sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crud-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx webpack && mkdirp dist && j2w -i build/bundle.js -o dist/crud-api.wasm", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "mkdirp": "^3.0.1", 15 | "webpack": "^5.74.0", 16 | "webpack-cli": "^4.10.0" 17 | }, 18 | "dependencies": { 19 | "@spinframework/build-tools": "^1.0.1", 20 | "@spinframework/spin-sqlite": "^1.0.0", 21 | "@spinframework/wasi-http-proxy": "^1.0.0", 22 | "itty-router": "^5.0.18", 23 | "uuid": "^11.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /http-crud-js-sqlite/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | authors = ["Fermyon Engineering "] 5 | description = "" 6 | name = "http-crud-js-sqlite" 7 | version = "0.1.0" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "crud-api" 12 | 13 | [component.crud-api] 14 | source = "dist/crud-api.wasm" 15 | exclude_files = ["**/node_modules"] 16 | sqlite_databases = ["default"] 17 | 18 | [component.crud-api.build] 19 | command = ["npm install", "npm run build"] 20 | watch = ["src/**/*.js"] 21 | -------------------------------------------------------------------------------- /http-crud-js-sqlite/src/index.js: -------------------------------------------------------------------------------- 1 | import { AutoRouter } from "itty-router"; 2 | import { 3 | deleteItemById, 4 | deleteManyItems, 5 | getAllItems, 6 | getItemById, 7 | createItem, 8 | updateItemById, 9 | notFound, 10 | } from "./handlers"; 11 | let router = AutoRouter(); 12 | 13 | router.get("/items", () => getAllItems()); 14 | router.get("/items/:id", ({ params }) => getItemById(params.id)); 15 | router.post("/items", async (req, { baseUrl }) => 16 | createItem(baseUrl, await req.arrayBuffer()), 17 | ); 18 | router.put("/items/:id", async (req, { baseUrl }) => 19 | updateItemById(baseUrl, req.params.id, await req.arrayBuffer()), 20 | ); 21 | router.delete("/items", async (req) => 22 | deleteManyItems(await req.arrayBuffer()), 23 | ); 24 | router.delete("/items/:id", ({ params }) => deleteItemById(params.id)); 25 | router.all("*", () => notFound("Endpoint not found")); 26 | 27 | addEventListener("fetch", (event) => { 28 | const fullUrl = event.request.headers.get("spin-full-url"); 29 | const path = event.request.headers.get("spin-path-info"); 30 | const baseUrl = fullUrl.substr(0, fullUrl.indexOf(path)); 31 | event.respondWith( 32 | router.fetch(event.request, { 33 | baseUrl, 34 | fullUrl, 35 | path, 36 | }), 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /http-crud-js-sqlite/webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import SpinSdkPlugin from "@spinframework/build-tools/plugins/webpack/index.js"; 3 | 4 | const config = async () => { 5 | let SpinPlugin = await SpinSdkPlugin.init() 6 | return { 7 | mode: 'production', 8 | stats: 'errors-only', 9 | entry: './src/index.js', 10 | experiments: { 11 | outputModule: true, 12 | }, 13 | resolve: { 14 | extensions: ['.js'], 15 | }, 16 | output: { 17 | path: path.resolve(process.cwd(), './build'), 18 | filename: 'bundle.js', 19 | module: true, 20 | library: { 21 | type: "module", 22 | } 23 | }, 24 | plugins: [ 25 | SpinPlugin 26 | ], 27 | optimization: { 28 | minimize: false 29 | }, 30 | }; 31 | } 32 | export default config -------------------------------------------------------------------------------- /http-crud-rust-mysql/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "http-crud-rust-mysql" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | serde = { version = "1.0.197", features = ["derive"] } 14 | serde_json = "1.0.115" 15 | spin-sdk = "3.0.0" 16 | uuid = { version = "1.8.0", features = ["v4"] } 17 | 18 | [workspace] 19 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | ENV MYSQL_HOST=mysql 3 | ENV MYSQL_USER=spin 4 | ENV MYSQL_PWD= 5 | ENV MYSQL_DATABASE=spin 6 | 7 | RUN apt-get update && apt-get install -y \ 8 | mysql-client \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | WORKDIR /app 12 | COPY scripts/mysql/init.sql . 13 | COPY scripts/mysql/entrypoint.sh . 14 | ENTRYPOINT [ "/app/entrypoint.sh" ] -------------------------------------------------------------------------------- /http-crud-rust-mysql/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: database-up 2 | database-up: 3 | docker run -d -e MYSQL_USER=spin -e MYSQL_PASSWORD=spin -e MYSQL_ROOT_PASSWORD=secure-pw -e MYSQL_DATABASE=spin -p 3306:3306 --name mysql mysql:latest 4 | echo "Waiting for DB to become ready..." 5 | sleep 3 6 | 7 | .PHONY: database-seed 8 | database-seed: 9 | docker build . -t mysql-seed 10 | docker run --rm -e MYSQL_PWD=spin --link mysql mysql-seed 11 | 12 | .PHONY: datbase-down 13 | database-down: 14 | docker rm -f mysql 15 | 16 | .PHONY: build 17 | build: 18 | spin build 19 | 20 | .PHONY: run 21 | run: 22 | spin up 23 | 24 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/README.md: -------------------------------------------------------------------------------- 1 | ## HTTP CRUD Sample 2 | 3 | This is a sample implementation of CRUD (Create, Read, Update, Delete) in Rust. 4 | 5 | The sample is using MySQL for persistence and provides the following API endpoints via HTTP: 6 | 7 | - `GET /items` - To retrieve a list of all items 8 | - `GET /items/:id` - To retrieve a item using its identifier 9 | - `POST /items` - To create a new item 10 | - `PUT /items/:id` - To update an existing item using its identifier 11 | - `DELETE /items` - To delete multiple items providing an array of identifiers as payload (`{ "ids": []}`) 12 | - `DELETE /items/:id` - To delete an existing item using its identifier 13 | 14 | Send data to `POST /items` and `PUT /items/:id` using the following structure: 15 | 16 | ```jsonc 17 | { 18 | "name": "item name", 19 | // boolean (either true or false) 20 | "active": true 21 | } 22 | ``` 23 | 24 | ## Supported Platforms 25 | 26 | - Local (`spin up`) 27 | - SpinKube 28 | - Fermyon Platform for Kubernetes 29 | - 30 | ## Prerequisites 31 | 32 | To run the sample on your local machine, you must have the following software installed: 33 | 34 | - Latest [Spin](https://developer.fermyon.com/spin) CLI 35 | - [Docker](https://docker.com) 36 | - [Rust](https://www.rust-lang.org/) installed on your machine 37 | - The `wasm32-wasi` target for Rust installed (`rustup target add wasm32-wasi`) 38 | 39 | 40 | ## Running the Sample 41 | 42 | ### Local (`spin up`) 43 | 44 | To run this sample locally, you can either follow the steps mentioned below or use the corresponding targets specified in the `Makefile`. 45 | 46 | ```bash 47 | # 1. Start a MySQL container 48 | ## alternatively you can run `make database-up` 49 | docker run -d -e MYSQL_USER=spin \ 50 | -e MYSQL_PASSWORD=spin \ 51 | -e MYSQL_ROOT_PASSWORD=secure-pw \ 52 | -e MYSQL_DATABASE=spin \ 53 | -p 3306:3306 --name mysql \ 54 | mysql:latest 55 | 56 | # 2. Build and Run the database seeding container 57 | ## alternatively, you can run `make database-seed` 58 | docker build . -t mysql-seed 59 | # snip 60 | docker run --rm -e MYSQL_PWD=spin --link mysql mysql-seed 61 | 62 | # 3. Build the Spin App 63 | ## alternatively, you can run `make build` 64 | spin build 65 | 66 | # 4. Run the Spin App 67 | ## alternatively, you can run `make run` 68 | spin up 69 | ``` 70 | 71 | At this point, you can invoke the API endpoints mentioned above at http://localhost:3000/ 72 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/scripts/mysql/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | mysql -h ${MYSQL_HOST} -u ${MYSQL_USER} -D ${MYSQL_DATABASE} < /app/init.sql -------------------------------------------------------------------------------- /http-crud-rust-mysql/scripts/mysql/init.sql: -------------------------------------------------------------------------------- 1 | USE spin; 2 | 3 | CREATE TABLE IF NOT EXISTS Items ( 4 | Id varchar(36) PRIMARY KEY, 5 | Name TEXT NOT NULL, 6 | Active BOOLEAN NOT NULL 7 | ); 8 | 9 | INSERT INTO Items(Id, Name, Active) 10 | SELECT '9ccc555f-e4f8-446c-83cb-ee1542862450', 'Lane Assistant', TRUE 11 | WHERE 12 | NOT EXISTS ( 13 | SELECT Id FROM Items WHERE Id = '9ccc555f-e4f8-446c-83cb-ee1542862450' 14 | ); 15 | 16 | INSERT INTO Items(Id, Name, Active) 17 | SELECT '35499593-ff71-4b13-8bd5-07ddf47bee6b', 'Sentry Mode', TRUE 18 | WHERE 19 | NOT EXISTS ( 20 | SELECT Id FROM Items WHERE Id = '35499593-ff71-4b13-8bd5-07ddf47bee6b' 21 | ); 22 | 23 | INSERT INTO Items(Id, Name, Active) 24 | SELECT 'eb74da9d-3bb5-4f4f-825a-9bb628f5691b', 'Autopilot', FALSE 25 | WHERE 26 | NOT EXISTS ( 27 | SELECT Id FROM Items WHERE Id = 'eb74da9d-3bb5-4f4f-825a-9bb628f5691b' 28 | ); 29 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "http-crud-rust-mysql" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "http-crud-rust-mysql" 12 | 13 | [component.http-crud-rust-mysql] 14 | source = "target/wasm32-wasi/release/http_crud_rust_mysql.wasm" 15 | allowed_outbound_hosts = ["mysql://localhost"] 16 | 17 | [component.http-crud-rust-mysql.variables] 18 | db_connection_string = "mysql://spin:spin@localhost:3306/spin" 19 | 20 | [component.http-crud-rust-mysql.build] 21 | command = "cargo build --target wasm32-wasip1 --release" 22 | watch = ["src/**/*.rs", "Cargo.toml"] 23 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/src/api.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use spin_sdk::{ 4 | http::{IntoResponse, Request, Router}, 5 | http_router, 6 | }; 7 | 8 | use crate::handlers; 9 | 10 | pub(crate) struct Api { 11 | router: Router, 12 | } 13 | 14 | impl Api { 15 | pub(crate) fn handle(&self, req: Request) -> anyhow::Result { 16 | Ok(self.router.handle(req)) 17 | } 18 | } 19 | 20 | impl Display for Api { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | write!(f, "{}", self.router) 23 | } 24 | } 25 | 26 | impl Default for Api { 27 | fn default() -> Self { 28 | let router = http_router!( 29 | GET "/items" => handlers::get_all, 30 | GET "/items/:id" => handlers::get_by_id, 31 | POST "/items" => handlers::create_item, 32 | PUT "/items/:id" => handlers::update_item_by_id, 33 | DELETE "/items" => handlers::delete_multiple_items, 34 | DELETE "/items/:id" => handlers::delete_by_id 35 | ); 36 | Self { router: router } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /http-crud-rust-mysql/src/lib.rs: -------------------------------------------------------------------------------- 1 | use api::Api; 2 | use spin_sdk::http::{IntoResponse, Request}; 3 | use spin_sdk::http_component; 4 | 5 | mod api; 6 | mod handlers; 7 | mod models; 8 | mod persistence; 9 | 10 | #[http_component] 11 | fn handle_crud(req: Request) -> anyhow::Result { 12 | let api = Api::default(); 13 | api.handle(req) 14 | } 15 | -------------------------------------------------------------------------------- /image-transformation/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /image-transformation/README.md: -------------------------------------------------------------------------------- 1 | # Image Transformation 2 | 3 | This folder contains a Spin App illustrating how one could leverage existing libraries from the language ecosystem (here crates) within Spin Apps. The app in this folder consists of two components. 4 | 5 | An API written in Rust and a simple frontend built with HTML5 and JavaScript. The API uses [photon_rs](https://docs.rs/photon-rs/latest/photon_rs/) for applying all sorts of transformations to an image. 6 | 7 | ## Supported Platforms 8 | 9 | - Local (`spin up`) 10 | - Fermyon Cloud 11 | - SpinKube 12 | - Fermyon Platform for Kubernetes 13 | 14 | ## Prerequisites 15 | 16 | To use this sample you must have 17 | 18 | - [Rust](https://www.rust-lang.org/) installed on your machine 19 | - The `wasm32-wasi` target for Rust installed (`rustup target add wasm32-wasi`) 20 | - [Spin](https://developer.fermyon.com/spin/v2/index) CLI installed on your machine 21 | 22 | ## Running the Sample 23 | 24 | ### Local (`spin up`) 25 | 26 | To run the sample locally, follow the steps shown in the snippet below: 27 | 28 | ```bash 29 | # Build the project 30 | spin build 31 | 32 | # Run the sample 33 | spin up 34 | Logging component stdio to ".spin/logs/" 35 | 36 | Serving http://127.0.0.1:3000 37 | Available Routes: 38 | api: http://127.0.0.1:3000/api (wildcard) 39 | frontend: http://127.0.0.1:3000 (wildcard) 40 | ``` 41 | 42 | ### Fermyon Cloud 43 | 44 | You can deploy this sample to Fermyon Cloud following the steps below: 45 | 46 | ```bash 47 | # Authenticate 48 | spin cloud login 49 | 50 | # Deploy the sample to Fermyon Cloud 51 | spin deploy 52 | Uploading image-transformation version 0.1.0 to Fermyon Cloud... 53 | Deploying... 54 | Waiting for application to become ready......... ready 55 | 56 | View application: https://image-transformation-zbxgelnm.fermyon.app/ 57 | Routes: 58 | - api: https://image-transformation-zbxgelnm.fermyon.app/api (wildcard) 59 | - frontend: https://image-transformation-zbxgelnm.fermyon.app (wildcard) 60 | Manage application: https://cloud.fermyon.com/app/image-transformation 61 | ``` 62 | 63 | ## Screenshot 64 | 65 | ![Image Transformation](./screenshot.png) 66 | -------------------------------------------------------------------------------- /image-transformation/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | photon-rs = { default-features = false, git = "https://github.com/silvia-odwyer/photon", rev = "8b17f68579e1b17edf3428a5ffcd22b4f99af6ec" } 14 | serde_json = "1.0.132" 15 | spin-sdk = "3.0.1" 16 | 17 | [workspace] 18 | -------------------------------------------------------------------------------- /image-transformation/api/src/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use anyhow::{Context, Result}; 4 | use photon_rs::{native::open_image_from_bytes, PhotonImage}; 5 | use spin_sdk::http::{Params, Response, ResponseBuilder}; 6 | 7 | pub(crate) fn get_image_from_request_body(body: &[u8]) -> Result { 8 | open_image_from_bytes(body).with_context(|| "error loading image from body") 9 | } 10 | 11 | pub(crate) fn send_jpg(img: &PhotonImage) -> Result { 12 | let bytes = img.get_bytes_jpeg(100); 13 | Ok(ResponseBuilder::new(200) 14 | .header("content-type", "image/jpeg") 15 | .body(bytes) 16 | .build()) 17 | } 18 | 19 | pub(crate) struct Dimension { 20 | pub width: u32, 21 | pub height: u32, 22 | } 23 | impl Display for Dimension { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | write!(f, "({}x{})", self.width, self.height) 26 | } 27 | } 28 | impl Dimension { 29 | pub(crate) fn new(params: Params, img: &PhotonImage) -> anyhow::Result { 30 | let width = params 31 | .get("width") 32 | .with_context(|| "width not found in params")?; 33 | let width: u32 = width.parse().with_context(|| "Invalid width provided")?; 34 | 35 | // calculate desired height while keeping aspect ratio 36 | let height = (img.get_height() * width) / img.get_width(); 37 | Ok(Dimension { width, height }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /image-transformation/frontend/app.js: -------------------------------------------------------------------------------- 1 | const filter = document.getElementById("filter"); 2 | const fileInput = document.getElementById("image"); 3 | 4 | const containerOriginalImage = document.getElementById("original"); 5 | const containerTransformedImage = document.getElementById("result"); 6 | const errorLabel = document.getElementById("err"); 7 | 8 | 9 | async function transform() { 10 | setError(); 11 | const file = fileInput.files[0]; 12 | if (!file) { 13 | return setError("No file selected"); 14 | } 15 | 16 | if (file.size > 1 * 1024 * 1024) { 17 | return setError("File size exceeds 1MB. Please select a smaller file."); 18 | } 19 | const arrBuf = await file.arrayBuffer(); 20 | const imageBytes = new Uint8Array(arrBuf); 21 | try { 22 | const response = await fetch(filter.value, { 23 | method: "POST", 24 | headers: { 25 | "content-type": "application/octet-stream", 26 | }, 27 | body: imageBytes 28 | }); 29 | if (!response.ok) throw new Error("Request returned a bad response code"); 30 | 31 | const transformedImageBlob = await response.blob(); 32 | appendImage(containerOriginalImage, new Blob([arrBuf], { type: file.type }), "Original Image"); 33 | appendImage(containerTransformedImage, transformedImageBlob, "Transformed Image"); 34 | } catch (err) { 35 | return setError(`Error while transforming image ${err}`); 36 | } finally { 37 | return false; 38 | } 39 | } 40 | 41 | function appendImage(container, blob, caption) { 42 | const url = URL.createObjectURL(blob); 43 | container.innerHTML = ''; 44 | const img = document.createElement("img"); 45 | img.src = url; 46 | img.alt = caption; 47 | img.style.maxWidth = "100%"; 48 | const header = document.createElement("h2"); 49 | header.innerText = caption; 50 | header.className = "text-xl text-center" 51 | container.appendChild(header); 52 | container.appendChild(img); 53 | } 54 | 55 | function setError(err) { 56 | if (err) { 57 | errorLabel.innerHTML = err; 58 | return false; 59 | } 60 | errorLabel.innerHTML = ""; 61 | return false; 62 | } 63 | 64 | -------------------------------------------------------------------------------- /image-transformation/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/enterprise-architectures-and-patterns/75818a626a6d91c630c95c524dbd1e60fd47eae6/image-transformation/screenshot.png -------------------------------------------------------------------------------- /image-transformation/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "image-transformation" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/api/..." 11 | component = "api" 12 | 13 | [component.api] 14 | source = "api/target/wasm32-wasi/release/api.wasm" 15 | allowed_outbound_hosts = [] 16 | 17 | [component.api.build] 18 | workdir = "api" 19 | command = "cargo build --target wasm32-wasip1 --release" 20 | watch = ["src/**/*.rs", "Cargo.toml"] 21 | 22 | [[trigger.http]] 23 | route = "/..." 24 | component = "frontend" 25 | 26 | [component.frontend] 27 | source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.2.1/spin_static_fs.wasm", digest = "sha256:5f05b15f0f7cd353d390bc5ebffec7fe25c6a6d7a05b9366c86dcb1a346e9f0f" } 28 | files = [{ source = "frontend", destination = "/" }] 29 | -------------------------------------------------------------------------------- /load-testing-spin-with-k6/README.md: -------------------------------------------------------------------------------- 1 | # Load-Testing Spin Apps with k6 2 | 3 | > Grafana [k6](https://k6.io/) is an open-source load testing tool that makes performance testing easy and productive for engineering teams. k6 is free, developer-centric, and extensible. 4 | 5 | Samples in this folder illustrate how to load test Spin applications. 6 | 7 | ## Install k6 8 | 9 | You can find detailed installation instruction for k6 at https://grafana.com/docs/k6/latest/get-started/installation/. For example, you can install k6 on macOS using the [Homebrew package manager](https://brew.sh/): 10 | 11 | ```bash 12 | # Install k6 13 | brew install k6 14 | ``` 15 | 16 | ## Building and Running the Spin App 17 | 18 | To build and run the sample Spin App, run the following commands: 19 | 20 | ```bash 21 | # Move into the scenario folder 22 | pushd scenario 23 | 24 | # Build the Spin App 25 | spin build 26 | 27 | # Run the Spin App 28 | spin up 29 | ``` 30 | 31 | ## k6 - Test Web Dashboard 32 | 33 | While tests are running, you can access tests result at [http://127.0.0.1:5665](http://127.0.0.1:5665). 34 | 35 | ## Running Smoke Test 36 | 37 | ### Run smoke tests against plain text endpoint use 38 | 39 | ```bash 40 | # Enable dashboard and run smoke tests 41 | K6_WEB_DASHBOARD=true k6 run smoke-test.js 42 | ``` 43 | 44 | ### Run smoke tests against JSON endpoint use 45 | 46 | ```bash 47 | # Enable dashboard and run smoke tests 48 | K6_WEB_DASHBOARD=true k6 run -e JSON=1 smoke-test.js 49 | ``` 50 | 51 | ## Running Stress Test 52 | 53 | ### Run stress tests against plain text endpoint use 54 | 55 | ```bash 56 | # Enable dashboard and run stress tests 57 | K6_WEB_DASHBOARD=true k6 run stress-test.js 58 | ``` 59 | 60 | ### Run stress tests against JSON endpoint use 61 | 62 | ```bash 63 | # Enable dashboard and run stress tests 64 | K6_WEB_DASHBOARD=true k6 run -e JSON=1 stress-test.js 65 | ``` 66 | 67 | ## Running Breakpoint Test 68 | 69 | ### Run breakpoint tests against plain text endpoint use 70 | 71 | ```bash 72 | # Enable dashboard and run breakpoint tests 73 | K6_WEB_DASHBOARD=true k6 run breakpoint-test.js 74 | ``` 75 | 76 | ### Run breakpoint tests against JSON endpoint use 77 | 78 | ```bash 79 | # Enable dashboard and run breakpoint tests 80 | K6_WEB_DASHBOARD=true k6 run -e JSON=1 breakpoint-test.js 81 | ``` -------------------------------------------------------------------------------- /load-testing-spin-with-k6/breakpoint-test.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { sleep } from 'k6'; 3 | import { check, fail } from 'k6'; 4 | 5 | export const options = { 6 | executor: 'ramping-arrival-rate', 7 | stages: [ 8 | { duration: '1m', target: 3000 }, 9 | ], 10 | }; 11 | 12 | const json = __ENV.JSON; 13 | let url = "http://localhost:3000"; 14 | if (json != "1") { 15 | url = `${url}/plain`; 16 | } 17 | 18 | export default () => { 19 | 20 | console.log(`Requesting ${url}`); 21 | const res = http.get(url); 22 | if ( 23 | !check(res, { 24 | 'Checking for status code 200': (res) => res.status == 200, 25 | }) 26 | ) { 27 | fail(`HTTP request failed. Received status ${res.status}`); 28 | } 29 | // wait for 0.5 sec after each iteration 30 | sleep(1) 31 | }; -------------------------------------------------------------------------------- /load-testing-spin-with-k6/scenario/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /load-testing-spin-with-k6/scenario/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "scenario" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | serde = { version = "1.0.197", features = ["derive"] } 14 | serde_json = "1.0.114" 15 | spin-sdk = "3.0.1" 16 | 17 | [workspace] 18 | -------------------------------------------------------------------------------- /load-testing-spin-with-k6/scenario/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "scenario" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "scenario" 12 | 13 | [component.scenario] 14 | source = "target/wasm32-wasi/release/scenario.wasm" 15 | allowed_outbound_hosts = [] 16 | [component.scenario.build] 17 | command = "cargo build --target wasm32-wasip1 --release" 18 | watch = ["src/**/*.rs", "Cargo.toml"] 19 | -------------------------------------------------------------------------------- /load-testing-spin-with-k6/scenario/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use spin_sdk::http::{IntoResponse, Params, Request, Response, Router}; 3 | use spin_sdk::http_component; 4 | 5 | #[http_component] 6 | fn handle_scenario(req: Request) -> anyhow::Result { 7 | let mut router = Router::default(); 8 | router.get("/", return_json); 9 | router.get("/plain", return_text); 10 | Ok(router.handle(req)) 11 | } 12 | 13 | fn return_json(_: Request, _: Params) -> anyhow::Result { 14 | let payload = serde_json::to_vec(&Sample::default())?; 15 | Ok(Response::builder() 16 | .status(200) 17 | .header("content-type", "application/json") 18 | .body(payload) 19 | .build()) 20 | } 21 | 22 | fn return_text(_: Request, _: Params) -> anyhow::Result { 23 | Ok(Response::builder() 24 | .status(200) 25 | .header("content-type", "text/plain") 26 | .body("Some static text value") 27 | .build()) 28 | } 29 | 30 | #[derive(Debug, Serialize)] 31 | pub struct Sample { 32 | value: String, 33 | key: i8, 34 | active: bool, 35 | } 36 | 37 | impl Default for Sample { 38 | fn default() -> Self { 39 | Self { 40 | value: "What is the meaning of life?".to_string(), 41 | key: 42, 42 | active: true, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /load-testing-spin-with-k6/smoke-test.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, fail, sleep } from 'k6'; 3 | 4 | export const options = { 5 | vus: 3, 6 | duration: '30s', 7 | }; 8 | 9 | const json = __ENV.JSON; 10 | let url = "http://localhost:3000"; 11 | if (json != "1") { 12 | url = `${url}/plain`; 13 | } 14 | export default () => { 15 | const response = http.get(url); 16 | if (!check(response, { 17 | "Testing Response Code": (r) => r.status == 200 18 | })) { 19 | fail(`HTTP request failed with status ${r.status}`); 20 | } 21 | 22 | if (!check(response, { 23 | "Testing Response Containes desired key": (r) => r.json()["key"] == 42, 24 | })) { 25 | fail(`HTTP response did not contain desired key (42)`); 26 | } 27 | sleep(1); 28 | }; -------------------------------------------------------------------------------- /load-testing-spin-with-k6/stress-test.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http'; 2 | import { check, fail, sleep } from 'k6'; 3 | export const options = { 4 | stages: [ 5 | { duration: '5s', target: 200 }, 6 | { duration: '5s', target: 220 }, 7 | { duration: '5s', target: 230 }, 8 | { duration: '10s', target: 240 }, 9 | { duration: '10s', target: 0 }, 10 | ], 11 | }; 12 | 13 | const json = __ENV.JSON; 14 | let url = "http://localhost:3000"; 15 | if (json != "1") { 16 | url = `${url}/plain`; 17 | } 18 | 19 | export default () => { 20 | const res = http.get(url); 21 | if ( 22 | !check(res, { 23 | 'Checking for status code 200': (res) => res.status == 200, 24 | }) 25 | ) { 26 | fail(`HTTP request failed. Received status ${res.status}`); 27 | } 28 | // wait for 0.3 sec after each iteration 29 | sleep(.3) 30 | }; -------------------------------------------------------------------------------- /long-running-jobs-over-http/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: start-mosquitto 2 | start-mosquitto: 3 | docker run -d -p 1883:1883 -p 9001:9001 -v ./mosquitto:/mosquitto/config:rw --name mosquitto eclipse-mosquitto 4 | 5 | .PHONY: stop-mosquitto 6 | stop-mosquitto: 7 | docker rm -f mosquitto 8 | 9 | .PHONY: start-api 10 | start-api: 11 | pushd api;\ 12 | spin up --sqlite @migrations.sql --runtime-config-file ./local.toml --build 13 | 14 | .PHONY: start-spin-worker 15 | start-spin-worker: 16 | pushd spin-worker;\ 17 | spin up --runtime-config-file local.toml --build 18 | 19 | 20 | .PHONY: start-native-worker 21 | start-native-worker: 22 | pushd native-worker;\ 23 | go run main.go --release 24 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | serde = { version = "1.0.197", features = ["derive"] } 14 | serde_json = "1.0.115" 15 | shared = { path = "../shared" } 16 | uuid = { version = "1.8.0", features = ["v4"] } 17 | spin-sdk = "3.0.1" 18 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/local.toml: -------------------------------------------------------------------------------- 1 | [sqlite_database.default] 2 | type = "spin" 3 | path = "../data/jobs.db" 4 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS Jobs ( 2 | Id VARCHAR(36) PRIMARY KEY, 3 | Input TEXT NOT NULL, 4 | Result TEXT NULL, 5 | Status INTEGER NOT NULL 6 | ); -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "long-running-jobs-over-http" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | mqtt_address = { default = "mqtt://localhost:1883" } 11 | mqtt_client_id = { default = "api" } 12 | mqtt_username = { default = "" } 13 | mqtt_password = { default = "" } 14 | mqtt_keep_alive = { default = "30" } 15 | 16 | [[trigger.http]] 17 | route = "/..." 18 | component = "api" 19 | 20 | [component.api] 21 | source = "target/wasm32-wasi/release/api.wasm" 22 | allowed_outbound_hosts = ["mqtt://localhost:1883"] 23 | sqlite_databases = ["default"] 24 | 25 | [component.api.variables] 26 | mqtt_address = "{{ mqtt_address }}" 27 | mqtt_client_id = "{{ mqtt_client_id }}" 28 | mqtt_username = "{{ mqtt_username }}" 29 | mqtt_password = "{{ mqtt_password }}" 30 | mqtt_keep_alive = "{{ mqtt_keep_alive }}" 31 | topic_name = "jobs/new" 32 | 33 | [component.api.build] 34 | command = "cargo build --target wasm32-wasip1 --release" 35 | watch = ["src/**/*.rs", "Cargo.toml"] 36 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/src/config.rs: -------------------------------------------------------------------------------- 1 | use spin_sdk::variables; 2 | 3 | pub(crate) struct Config { 4 | mqtt_address: String, 5 | mqtt_client_id: String, 6 | pub(crate) mqtt_username: String, 7 | pub(crate) mqtt_password: String, 8 | pub(crate) mqtt_keep_alive: u64, 9 | pub(crate) topic_name: String, 10 | } 11 | 12 | impl Config { 13 | pub fn load() -> anyhow::Result { 14 | Ok(Config { 15 | mqtt_address: variables::get("mqtt_address")?, 16 | mqtt_client_id: variables::get("mqtt_client_id").unwrap_or(String::from("client001")), 17 | mqtt_username: variables::get("mqtt_username").unwrap_or_default(), 18 | mqtt_password: variables::get("mqtt_password").unwrap_or_default(), 19 | mqtt_keep_alive: variables::get("mqtt_keep_alive")?.parse().unwrap_or(30), 20 | topic_name: variables::get("topic_name").unwrap_or(String::from("jobs/new")), 21 | }) 22 | } 23 | 24 | pub fn get_connection_string(&self) -> String { 25 | format!("{}?client_id={}", self.mqtt_address, self.mqtt_client_id) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/src/handlers.rs: -------------------------------------------------------------------------------- 1 | use spin_sdk::http::{responses::not_found, HeaderValue, IntoResponse, Params, Request, Response}; 2 | 3 | use crate::{ 4 | models::CreateJobModel, 5 | service::{create_job, read_job_status, read_job_status_all}, 6 | }; 7 | 8 | pub fn start_job(req: Request, _: Params) -> anyhow::Result { 9 | let Ok(model) = serde_json::from_slice::(req.body()) else { 10 | return Ok(Response::new(400, ())); 11 | }; 12 | let Ok(created) = create_job(model) else { 13 | return Ok(Response::new(500, ())); 14 | }; 15 | let location_header_value = 16 | build_location_header_value(req.header("spin-full-url"), &created.id); 17 | 18 | Ok(Response::builder() 19 | .status(201) 20 | .header("Content-Type", "application/json") 21 | .header("Location", location_header_value) 22 | .body(created) 23 | .build()) 24 | } 25 | 26 | pub fn get_job_status(_: Request, params: Params) -> anyhow::Result { 27 | let Some(id) = params.get("id") else { 28 | return Ok(Response::new(400, ())); 29 | }; 30 | match read_job_status(id.to_string())? { 31 | Some(status) => Ok(Response::builder() 32 | .status(200) 33 | .header("Content-Type", "application/json") 34 | .body(status) 35 | .build()), 36 | None => Ok(not_found()), 37 | } 38 | } 39 | 40 | pub fn get_status_of_all_jobs(_: Request, _: Params) -> anyhow::Result { 41 | let status = read_job_status_all()?; 42 | Ok(Response::builder() 43 | .status(200) 44 | .header("Content-Type", "application/json") 45 | .body(status) 46 | .build()) 47 | } 48 | 49 | fn build_location_header_value(url_header: Option<&HeaderValue>, id: &str) -> String { 50 | if url_header.is_none() { 51 | return format!("/{}", id); 52 | } 53 | let url = url_header.unwrap().as_str().unwrap_or_default(); 54 | if url.ends_with("/") { 55 | return format!("{}{}", url, id); 56 | } 57 | format!("{}/{}", url, id) 58 | } 59 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/src/lib.rs: -------------------------------------------------------------------------------- 1 | use spin_sdk::http::{IntoResponse, Request}; 2 | use spin_sdk::{http_component, http_router}; 3 | 4 | mod config; 5 | mod handlers; 6 | mod models; 7 | mod service; 8 | 9 | /// A simple Spin HTTP component. 10 | #[http_component] 11 | fn handle_api(req: Request) -> anyhow::Result { 12 | let router = http_router!( 13 | POST "/jobs" => handlers::start_job, 14 | GET "/jobs/:id" => handlers::get_job_status, 15 | GET "/jobs" => handlers::get_status_of_all_jobs 16 | ); 17 | 18 | Ok(router.handle(req)) 19 | } 20 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use shared::{Job, JobStatus}; 3 | use spin_sdk::{http::conversions::IntoBody, sqlite::Row}; 4 | 5 | impl From for Job { 6 | fn from(value: CreateJobModel) -> Self { 7 | Self { 8 | id: uuid::Uuid::new_v4().to_string(), 9 | input: value.input, 10 | result: String::new(), 11 | status: JobStatus::Pending, 12 | } 13 | } 14 | } 15 | 16 | #[derive(Deserialize)] 17 | pub(crate) struct CreateJobModel { 18 | pub input: String, 19 | } 20 | 21 | #[derive(Serialize)] 22 | pub(crate) struct JobStatusModel { 23 | pub id: String, 24 | pub status: JobStatus, 25 | pub result: String, 26 | } 27 | 28 | impl From> for JobStatusModel { 29 | fn from(value: Row) -> Self { 30 | JobStatusModel { 31 | id: String::from(value.get::<&str>("Id").unwrap()), 32 | result: String::from(value.get::<&str>("Result").unwrap_or_default()), 33 | status: JobStatus::from(value.get::("Status").unwrap()), 34 | } 35 | } 36 | } 37 | 38 | impl From for JobStatusModel { 39 | fn from(value: Job) -> Self { 40 | Self { 41 | id: value.id, 42 | result: value.result, 43 | status: value.status, 44 | } 45 | } 46 | } 47 | impl IntoBody for JobStatusModel { 48 | fn into_body(self) -> Vec { 49 | serde_json::to_vec(&self).unwrap() 50 | } 51 | } 52 | 53 | pub(crate) struct JobStatusList { 54 | status: Vec, 55 | } 56 | 57 | impl From> for JobStatusList { 58 | fn from(value: Vec) -> Self { 59 | Self { status: value } 60 | } 61 | } 62 | 63 | impl IntoBody for JobStatusList { 64 | fn into_body(self) -> Vec { 65 | serde_json::to_vec(&self.status).unwrap() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/api/src/service.rs: -------------------------------------------------------------------------------- 1 | use shared::{Job, JobStatus}; 2 | use spin_sdk::{ 3 | mqtt::Connection, 4 | sqlite::{self, Value}, 5 | }; 6 | 7 | use crate::{ 8 | config::Config, 9 | models::{CreateJobModel, JobStatusList, JobStatusModel}, 10 | }; 11 | 12 | const SQL_INSERT_JOB: &str = "INSERT INTO Jobs (Id, Input, Status) VALUES (?,?,?)"; 13 | const SQL_READ_JOB_STATUS: &str = "SELECT Id, Result, Status FROM Jobs WHERE Id=?"; 14 | const SQL_READ_ALL_JOBS_STATUS: &str = "SELECT Id, Result, Status FROM Jobs"; 15 | pub fn create_job(model: CreateJobModel) -> anyhow::Result { 16 | let config = Config::load()?; 17 | 18 | let job = Job::from(model); 19 | let params = [ 20 | Value::Text(job.id.clone()), 21 | Value::Text(job.input.clone()), 22 | Value::Integer(JobStatus::Pending as i64), 23 | ]; 24 | let db = sqlite::Connection::open_default()?; 25 | match db.execute(SQL_INSERT_JOB, ¶ms) { 26 | Ok(_) => (), 27 | Err(e) => { 28 | println!("{}", e); 29 | () 30 | } 31 | }; 32 | let con = Connection::open( 33 | &config.get_connection_string(), 34 | &config.mqtt_username, 35 | &config.mqtt_password, 36 | config.mqtt_keep_alive, 37 | )?; 38 | let payload = serde_json::to_vec(&job)?; 39 | con.publish( 40 | &config.topic_name, 41 | &payload, 42 | spin_sdk::mqtt::Qos::ExactlyOnce, 43 | )?; 44 | let result = JobStatusModel::from(job); 45 | Ok(result) 46 | } 47 | 48 | pub fn read_job_status(id: String) -> anyhow::Result> { 49 | let con = sqlite::Connection::open_default()?; 50 | 51 | let params = [sqlite::Value::Text(id)]; 52 | let row_set = con.execute(SQL_READ_JOB_STATUS, ¶ms)?; 53 | let status: Vec = row_set 54 | .rows() 55 | .map(|row| JobStatusModel::from(row)) 56 | .collect(); 57 | Ok(status.into_iter().next()) 58 | } 59 | 60 | pub fn read_job_status_all() -> anyhow::Result { 61 | let con = sqlite::Connection::open_default()?; 62 | let params = []; 63 | let row_set = con.execute(SQL_READ_ALL_JOBS_STATUS, ¶ms)?; 64 | let list: JobStatusList = row_set 65 | .rows() 66 | .map(|row| JobStatusModel::from(row)) 67 | .collect::>() 68 | .into(); 69 | Ok(list) 70 | } 71 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/data/.gitignore: -------------------------------------------------------------------------------- 1 | jobs.db -------------------------------------------------------------------------------- /long-running-jobs-over-http/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/enterprise-architectures-and-patterns/75818a626a6d91c630c95c524dbd1e60fd47eae6/long-running-jobs-over-http/data/.gitkeep -------------------------------------------------------------------------------- /long-running-jobs-over-http/mosquitto/mosquitto.conf: -------------------------------------------------------------------------------- 1 | allow_anonymous true 2 | listener 1883 3 | listener 9001 4 | protocol websockets 5 | persistence true 6 | persistence_file mosquitto.db 7 | persistence_location /mosquitto/data/ -------------------------------------------------------------------------------- /long-running-jobs-over-http/native-worker/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fermyon/enterprise-architectures-and-patterns/long-running-jobs-over-http/non-spin-worker 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/eclipse/paho.mqtt.golang v1.4.3 7 | github.com/mattn/go-sqlite3 v1.14.22 8 | ) 9 | 10 | require ( 11 | github.com/gorilla/websocket v1.5.1 // indirect 12 | golang.org/x/net v0.24.0 // indirect 13 | golang.org/x/sync v0.7.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/native-worker/go.sum: -------------------------------------------------------------------------------- 1 | github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= 2 | github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= 3 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 4 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 5 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 6 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 7 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 8 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 9 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 10 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 11 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/shared/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/shared/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "itoa" 7 | version = "1.0.11" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 10 | 11 | [[package]] 12 | name = "proc-macro2" 13 | version = "1.0.79" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" 16 | dependencies = [ 17 | "unicode-ident", 18 | ] 19 | 20 | [[package]] 21 | name = "quote" 22 | version = "1.0.35" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 25 | dependencies = [ 26 | "proc-macro2", 27 | ] 28 | 29 | [[package]] 30 | name = "ryu" 31 | version = "1.0.17" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" 34 | 35 | [[package]] 36 | name = "serde" 37 | version = "1.0.197" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" 40 | dependencies = [ 41 | "serde_derive", 42 | ] 43 | 44 | [[package]] 45 | name = "serde_derive" 46 | version = "1.0.197" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" 49 | dependencies = [ 50 | "proc-macro2", 51 | "quote", 52 | "syn", 53 | ] 54 | 55 | [[package]] 56 | name = "serde_json" 57 | version = "1.0.115" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" 60 | dependencies = [ 61 | "itoa", 62 | "ryu", 63 | "serde", 64 | ] 65 | 66 | [[package]] 67 | name = "shared" 68 | version = "0.1.0" 69 | dependencies = [ 70 | "serde", 71 | "serde_json", 72 | ] 73 | 74 | [[package]] 75 | name = "syn" 76 | version = "2.0.58" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" 79 | dependencies = [ 80 | "proc-macro2", 81 | "quote", 82 | "unicode-ident", 83 | ] 84 | 85 | [[package]] 86 | name = "unicode-ident" 87 | version = "1.0.12" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 90 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | name = "shared" 8 | 9 | [dependencies] 10 | serde = { version = "1.0.197", features = ["derive"] } 11 | serde_json = "1.0.115" 12 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize)] 4 | pub struct Job { 5 | pub id: String, 6 | pub input: String, 7 | pub result: String, 8 | pub status: JobStatus, 9 | } 10 | 11 | #[derive(Deserialize, Serialize)] 12 | pub enum JobStatus { 13 | Pending, 14 | Created, 15 | Running, 16 | Succeeded, 17 | Failed, 18 | } 19 | 20 | impl From for JobStatus { 21 | fn from(value: u32) -> Self { 22 | match value { 23 | 0 => JobStatus::Pending, 24 | 1 => JobStatus::Running, 25 | 2 => JobStatus::Succeeded, 26 | _ => JobStatus::Failed, 27 | } 28 | } 29 | } 30 | 31 | impl Into for JobStatus { 32 | fn into(self) -> i64 { 33 | self as i64 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/spin-worker/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/spin-worker/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "worker" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | chrono = "*" 14 | serde = { version = "1.0.197", features = ["derive"] } 15 | serde_json = "1.0.115" 16 | spin-mqtt-sdk = { git = "https://github.com/spinkube/spin-trigger-mqtt" } 17 | spin-sdk = "3.0.1" 18 | shared = { path = "../shared" } 19 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/spin-worker/local.toml: -------------------------------------------------------------------------------- 1 | [sqlite_database.default] 2 | type = "spin" 3 | path = "../data/jobs.db" 4 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/spin-worker/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | [application] 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | name = "test2" 6 | version = "0.1.0" 7 | 8 | [variables] 9 | mqtt_address = { default = "mqtt://localhost:1883" } 10 | mqtt_client_id = { default = "worker" } 11 | mqtt_username = { default = "" } 12 | mqtt_password = { default = "" } 13 | mqtt_keep_alive = { default = "30" } 14 | 15 | [application.trigger.mqtt] 16 | address = "{{ mqtt_address }}?client_id={{ mqtt_client_id }}" 17 | username = "{{ mqtt_username }}" 18 | password = "{{ mqtt_password }}" 19 | keep_alive_interval = "30" 20 | 21 | [[trigger.mqtt]] 22 | topic = "jobs/new" 23 | qos = "2" 24 | component = "worker" 25 | 26 | [component.worker] 27 | source = "target/wasm32-wasi/release/worker.wasm" 28 | allowed_outbound_hosts = ["mqtt://localhost:1883"] 29 | sqlite_databases = ["default"] 30 | 31 | [component.worker.variables] 32 | mqtt_address = "{{ mqtt_address }}" 33 | mqtt_username = "{{ mqtt_username }}" 34 | mqtt_password = "{{ mqtt_password }}" 35 | mqtt_keep_alive = "{{ mqtt_keep_alive }}" 36 | 37 | [component.worker.build] 38 | command = "cargo build --target wasm32-wasip1 --release" 39 | watch = ["src/**/*.rs", "Cargo.toml"] 40 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/spin-worker/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use chrono::{DateTime, Utc}; 3 | 4 | use shared::Job; 5 | use spin_mqtt_sdk::{mqtt_component, Payload}; 6 | 7 | use status_reporter::report_job_status; 8 | use std::{self, thread::sleep, time::Duration}; 9 | 10 | mod status_reporter; 11 | 12 | const MAGIC_WORD: &str = "foobar"; 13 | 14 | #[mqtt_component] 15 | fn handle_message(payload: Payload) -> anyhow::Result<()> { 16 | log("Received a Job request"); 17 | let req = serde_json::from_slice::(&payload)?; 18 | report_job_status(&req.id, shared::JobStatus::Running, String::new())?; 19 | match simulate_job(req.input) { 20 | Ok(result) => { 21 | report_job_status(&req.id, shared::JobStatus::Succeeded, result)?; 22 | log("Succeeded") 23 | } 24 | Err(e) => { 25 | report_job_status(&req.id, shared::JobStatus::Failed, e.to_string())?; 26 | log("Failed.") 27 | } 28 | } 29 | log("Job processed."); 30 | Ok(()) 31 | } 32 | 33 | fn simulate_job(input: String) -> anyhow::Result { 34 | sleep(Duration::from_secs(60)); 35 | if input.to_lowercase().eq(MAGIC_WORD) { 36 | bail!("Received '{}' as input", MAGIC_WORD) 37 | } 38 | Ok(format!("{}!", input)) 39 | } 40 | 41 | fn log(msg: &str) { 42 | let dt: DateTime = std::time::SystemTime::now().into(); 43 | let dt = dt.format("%H:%M:%S.%f").to_string(); 44 | println!("{:?}: {}", dt, msg); 45 | } 46 | -------------------------------------------------------------------------------- /long-running-jobs-over-http/spin-worker/src/status_reporter.rs: -------------------------------------------------------------------------------- 1 | use shared::JobStatus; 2 | use spin_sdk::sqlite::{Connection, Value}; 3 | 4 | const SQL_UPDATE_JOB_STATUS: &str = "UPDATE Jobs SET Status=?, Result=? WHERE Id=?"; 5 | pub fn report_job_status(id: &str, status: JobStatus, result: String) -> anyhow::Result<()> { 6 | let con = Connection::open_default()?; 7 | let params = [ 8 | Value::Integer(status as i64), 9 | Value::Text(result), 10 | Value::Text(id.to_string()), 11 | ]; 12 | con.execute(SQL_UPDATE_JOB_STATUS, ¶ms)?; 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /pub-sub-polyglot/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /pub-sub-polyglot/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: start-redis 2 | start-redis: 3 | docker run --name redis -d -p 6379:6379 redis 4 | 5 | .PHONY: stop-redis 6 | stop-redis: 7 | docker rm -f redis 8 | 9 | .PHONY: start-go-subscriber 10 | start-go-subscriber: 11 | pushd subscriber-go;\ 12 | SPIN_VARIABLE_REDIS_CONNECTION_STRING=redis://localhost:6379 SPIN_VARIABLE_REDIS_CHANNEL=demochannel spin up --build 13 | 14 | .PHONY: start-rust-subscriber 15 | start-rust-subscriber: 16 | pushd subscriber-rust;\ 17 | SPIN_VARIABLE_REDIS_CONNECTION_STRING=redis://localhost:6379 SPIN_VARIABLE_REDIS_CHANNEL=demochannel spin up --build 18 | 19 | .PHONY: start-mass-publisher 20 | start-mass-publisher: 21 | pushd mass-publisher;\ 22 | REDIS_CONNECTION_STRING=redis://localhost:6379 REDIS_CHANNEL=demochannel cargo run --release 23 | 24 | .PHONY: start-http-publisher 25 | start-http-publisher: 26 | pushd http-publisher;\ 27 | SPIN_VARIABLE_REDIS_CONNECTION_STRING=redis://localhost:6379 SPIN_VARIABLE_REDIS_CHANNEL=demochannel spin up --build -------------------------------------------------------------------------------- /pub-sub-polyglot/README.md: -------------------------------------------------------------------------------- 1 | # Publish-Subscribe 2 | 3 | This folder contains a Publish-Subscribe pattern implemented using different programming languages. 4 | 5 | ## What is Publish-Subscribe 6 | 7 | The Publish-Subscribe pattern is a messaging pattern widely used in distributed systems to facilitate communication between multiple components or modules in a decoupled manner. In this pattern, publishers are responsible for producing messages containing data or events of interest, while subscribers express interest in specific types of messages by subscribing to relevant topics or channels. When a publisher generates a message, it is broadcasted to all subscribed subscribers without the publisher needing to have any knowledge of the subscribers' identities or how they process the messages. This decoupling enables loose coupling between components, making systems more flexible, scalable, and easier to maintain. 8 | 9 | Subscribers can react to messages they are interested in by executing predefined actions or processing the data contained within the messages. This pattern is commonly implemented using message brokers or event buses, where publishers send messages to a centralized location and subscribers receive messages from this central hub. By leveraging Publish-Subscribe, you can design systems where components are highly modular and can be easily extended or modified without affecting other parts of the system. Additionally, this pattern supports asynchronous communication, enabling efficient handling of large volumes of messages and improving system responsiveness. 10 | 11 | ## Supported Platforms 12 | 13 | - Local (`spin up`) requires a running redis cache 14 | - SpinKube 15 | - Fermyon Platform for Kubernetes 16 | 17 | ## Prerequisites 18 | 19 | - [Rust](https://www.rust-lang.org/) installed on your machine 20 | - The `wasm32-wasi` target for Rust installed (`rustup target add wasm32-wasi`) 21 | - [TinyGo](https://tinygo.org/) installed on your machine 22 | - [Node.js](https://nodejs.org/) installed on your machine 23 | - [Docker](https://docker.com) installed on your machine (for running Redis in a container) 24 | - [Spin](https://developer.fermyon.com/spin/v2/index) CLI installed on your machine 25 | 26 | ## Running the Sample 27 | 28 | ### Local (`spin up`) 29 | 30 | To run the sample locally, you can use different targets specified in the `Makefile`. Follow the steps below to continuously publish messages into a Redis channel using the [`mass-publisher`](./mass-publisher-rust/) and have two subscribers [`subscriber-go`](./subscriber-go) and [`subscriber-rust`](./subscriber-rust): 31 | 32 | ```bash 33 | # Start Redis 34 | make start-redis 35 | 36 | # run the mass-publisher 37 | make start-mass-publisher 38 | ``` 39 | 40 | Start the `subscriber-go` in a new terminal instance: 41 | 42 | ```bash 43 | # Start subscriber-go 44 | make start-subscriber-go 45 | ``` 46 | 47 | Start the `subscriber-rust` in a new terminal instance: 48 | 49 | ```bash 50 | # Start subscriber-rust 51 | make start-subscriber-rust 52 | ``` -------------------------------------------------------------------------------- /pub-sub-polyglot/http-publisher-js/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | target 4 | .spin/ 5 | build/ -------------------------------------------------------------------------------- /pub-sub-polyglot/http-publisher-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-publisher", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npx webpack && mkdirp dist && j2w -i build/bundle.js -o dist/http-publisher.wasm", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "mkdirp": "^3.0.1", 15 | "webpack": "^5.74.0", 16 | "webpack-cli": "^4.10.0" 17 | }, 18 | "dependencies": { 19 | "@spinframework/build-tools": "^1.0.1", 20 | "@spinframework/spin-redis": "^1.0.0", 21 | "@spinframework/spin-variables": "^1.0.0", 22 | "@spinframework/wasi-http-proxy": "^1.0.0", 23 | "hono": "^4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pub-sub-polyglot/http-publisher-js/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | authors = ["Thorsten Hans "] 5 | description = "" 6 | name = "http-publisher-js" 7 | version = "0.1.0" 8 | 9 | [variables] 10 | redis_connection_string = { required = true } 11 | redis_channel = { required = true } 12 | 13 | [[trigger.http]] 14 | route = "/..." 15 | component = "http-publisher" 16 | 17 | [component.http-publisher] 18 | source = "dist/http-publisher.wasm" 19 | exclude_files = ["**/node_modules"] 20 | allowed_outbound_hosts = ["{{redis_connection_string}}"] 21 | 22 | [component.http-publisher.variables] 23 | redis_connection_string = "{{ redis_connection_string }}" 24 | redis_channel = "{{ redis_channel }}" 25 | 26 | [component.http-publisher.build] 27 | command = ["npm install", "npm run build"] 28 | watch = ["src/**/*.js"] 29 | -------------------------------------------------------------------------------- /pub-sub-polyglot/http-publisher-js/src/index.js: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { logger } from "hono/logger"; 3 | import * as Redis from "@spinframework/spin-redis"; 4 | import * as Variables from "@spinframework/spin-variables"; 5 | 6 | let app = new Hono(); 7 | const enc = new TextEncoder(); 8 | app.use(logger()); 9 | 10 | app.post("/", (c) => { 11 | const redisConnectionString = Variables.get("redis_connection_string"); 12 | const redisChannel = Variables.get("redis_channel"); 13 | 14 | if (!redisConnectionString || !redisChannel) { 15 | console.log("Redis Connection is not configured"); 16 | c.status(500); 17 | return c.text("Internal Server Error"); 18 | } 19 | const r = Redis.open(redisConnectionString); 20 | r.publish(redisChannel, enc.encode("Hello from Spin").buffer); 21 | return c.text("Your message has been submitted to Redis"); 22 | }); 23 | 24 | app.fire(); 25 | -------------------------------------------------------------------------------- /pub-sub-polyglot/http-publisher-js/webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import SpinSdkPlugin from "@spinframework/build-tools/plugins/webpack/index.js"; 3 | 4 | const config = async () => { 5 | let SpinPlugin = await SpinSdkPlugin.init() 6 | return { 7 | mode: 'production', 8 | stats: 'errors-only', 9 | entry: './src/index.js', 10 | experiments: { 11 | outputModule: true, 12 | }, 13 | resolve: { 14 | extensions: ['.js'], 15 | }, 16 | output: { 17 | path: path.resolve(process.cwd(), './build'), 18 | filename: 'bundle.js', 19 | module: true, 20 | library: { 21 | type: "module", 22 | } 23 | }, 24 | plugins: [ 25 | SpinPlugin 26 | ], 27 | optimization: { 28 | minimize: false 29 | }, 30 | }; 31 | } 32 | export default config -------------------------------------------------------------------------------- /pub-sub-polyglot/mass-publisher/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /pub-sub-polyglot/mass-publisher/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mass-publisher" 3 | authors = ["Fermyon Engineering "] 4 | version = "0.1.0" 5 | edition = "2021" 6 | 7 | [dependencies] 8 | anyhow = "1.0.81" 9 | chrono = "0.4.35" 10 | redis = "0.24.0" 11 | -------------------------------------------------------------------------------- /pub-sub-polyglot/mass-publisher/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::thread::sleep; 2 | 3 | use chrono::Utc; 4 | use redis::Commands; 5 | 6 | const VAR_CONNECTION_STRING: &str = "REDIS_CONNECTION_STRING"; 7 | const VAR_CHANNEL: &str = "REDIS_CHANNEL"; 8 | const SLEEP_FOR: std::time::Duration = std::time::Duration::from_millis(500); 9 | fn main() -> anyhow::Result<()> { 10 | println!("--------------\nMass Publisher\n--------------\n\nMass Publisher will publish new messages every {:?}.\n\nPress Ctrl+C to terminate this process\n...", 11 | SLEEP_FOR 12 | ); 13 | let redis_connection_string = std::env::var(VAR_CONNECTION_STRING) 14 | .expect("Please set Redis connection string using the `REDIS_CONNECTION_STRING` environment variable"); 15 | let channel = std::env::var(VAR_CHANNEL) 16 | .expect("Please set Redis channel using the `REDIS_CHANNEL` environment variable"); 17 | let client = redis::Client::open(redis_connection_string)?; 18 | let mut con = client.get_connection()?; 19 | loop { 20 | let time = Utc::now(); 21 | let message = format!( 22 | "This is a message created by Mass Publisher at {}", 23 | time.to_rfc3339() 24 | ); 25 | println!("Publishing message..."); 26 | con.publish(&channel, message)?; 27 | sleep(SLEEP_FOR); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-go/.gitignore: -------------------------------------------------------------------------------- 1 | main.wasm 2 | .spin/ 3 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fermyon/enterprise-architectures-and-patterns/subscriber-go 2 | 3 | go 1.23 4 | 5 | require github.com/fermyon/spin/sdk/go/v2 v2.2.0 6 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/fermyon/spin/sdk/go/v2 v2.2.0 h1:zHZdIqjbUwyxiwdygHItnM+vUUNSZ3CX43jbIUemBI4= 2 | github.com/fermyon/spin/sdk/go/v2 v2.2.0/go.mod h1:kfJ+gdf/xIaKrsC6JHCUDYMv2Bzib1ohFIYUzvP+SCw= 3 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fermyon/spin/sdk/go/v2/redis" 7 | ) 8 | 9 | func init() { 10 | redis.Handle(func(payload []byte) error { 11 | fmt.Println("Received Message via Redis Channel") 12 | fmt.Println(string(payload)) 13 | return nil 14 | }) 15 | } 16 | 17 | func main() {} 18 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-go/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "subscriber-go" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | redis_connection_string = { required = true } 11 | redis_channel = { required = true } 12 | 13 | [application.trigger.redis] 14 | address = "{{ redis_connection_string }}" 15 | 16 | [[trigger.redis]] 17 | channel = "{{ redis_channel }}" 18 | component = "subscriber-go" 19 | 20 | [component.subscriber-go] 21 | source = "main.wasm" 22 | allowed_outbound_hosts = [] 23 | 24 | [component.subscriber-go.build] 25 | command = "tinygo build -target=wasip1 -gc=leaking -buildmode=c-shared -no-debug -o main.wasm ." 26 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-rust/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "subscriber-rust" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | # Useful crate to handle errors. 13 | anyhow = "1" 14 | # Crate to simplify working with bytes. 15 | bytes = "1" 16 | # The Spin SDK. 17 | spin-sdk = "3.0.1" 18 | 19 | [workspace] 20 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-rust/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "subscriber-rust" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [variables] 10 | redis_connection_string = { required = true } 11 | redis_channel = { required = true } 12 | 13 | [application.trigger.redis] 14 | address = "{{ redis_connection_string }}" 15 | 16 | [[trigger.redis]] 17 | channel = "{{ redis_channel }}" 18 | component = "subscriber-rust" 19 | 20 | [component.subscriber-rust] 21 | source = "target/wasm32-wasi/release/subscriber_rust.wasm" 22 | allowed_outbound_hosts = [] 23 | [component.subscriber-rust.build] 24 | command = "cargo build --target wasm32-wasip1 --release" 25 | -------------------------------------------------------------------------------- /pub-sub-polyglot/subscriber-rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use bytes::Bytes; 3 | use spin_sdk::redis_component; 4 | use std::str::from_utf8; 5 | 6 | #[redis_component] 7 | fn on_message(message: Bytes) -> Result<()> { 8 | println!("Received Message via Redis Channel"); 9 | println!("{}", from_utf8(&message)?); 10 | Ok(()) 11 | } 12 | -------------------------------------------------------------------------------- /signed-webhooks/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY register-consumer: 2 | register-consumer: 3 | curl -X DELETE http://localhost:3000/registrations 4 | curl -X POST -H 'Content-Type: application/json' -d '{"url": "http://localhost:3002/target", "event": "*"}' http://localhost:3000/registrations 5 | 6 | .PHONY fire-webhook: 7 | curl -iX POST http://localhost:3000/fire 8 | 9 | .PHONY build-all: build-hmac build-producer build-consumer 10 | 11 | .PHONY build-hmac: 12 | build-hmac: 13 | pushd hmac;\ 14 | make build 15 | 16 | .PHONY build-producer: 17 | build-producer: 18 | pushd webhook-producer;\ 19 | make build 20 | 21 | .PHONY build-consumer: 22 | build-consumer: 23 | pushd webhook-consumer;\ 24 | make build 25 | 26 | .PHONY start-producer: 27 | start-producer: 28 | pushd webhook-producer;\ 29 | spin up --listen 127.0.0.1:3000 --sqlite @migrations.sql 30 | 31 | .PHONY start-consumer: 32 | start-consumer: 33 | pushd webhook-consumer;\ 34 | spin up --listen 127.0.0.1:3002 35 | 36 | .PHONY install-deps: 37 | install-deps: 38 | cargo install cargo-binstall 39 | echo "yes" | cargo binstall wasm-tools --force 40 | echo "yes" | cargo binstall cargo-component --force 41 | 42 | -------------------------------------------------------------------------------- /signed-webhooks/hmac/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /signed-webhooks/hmac/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.check.overrideCommand": [ 3 | "cargo", 4 | "component", 5 | "check", 6 | "--workspace", 7 | "--all-targets", 8 | "--message-format=json" 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /signed-webhooks/hmac/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hmac" 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 | data-encoding = "2.5.0" 10 | rand = "0.8.5" 11 | ring = "0.17.8" 12 | wit-bindgen = { version = "0.24.0", default-features = false, features = [ 13 | "realloc", 14 | ] } 15 | 16 | wit-bindgen-rt = "0.24.0" 17 | 18 | [lib] 19 | crate-type = ["cdylib"] 20 | 21 | [package.metadata.component] 22 | package = "fermyon:hmac" 23 | 24 | [package.metadata.component.dependencies] 25 | -------------------------------------------------------------------------------- /signed-webhooks/hmac/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY build: 2 | build: 3 | cargo component build --release -------------------------------------------------------------------------------- /signed-webhooks/hmac/src/lib.rs: -------------------------------------------------------------------------------- 1 | use bindings::exports::fermyon::hmac::{sign, verify}; 2 | use data_encoding::HEXUPPER; 3 | use ring::hmac; 4 | use wit_bindgen::rt::vec::Vec; 5 | mod bindings; 6 | 7 | struct Component; 8 | 9 | impl sign::Guest for Component { 10 | fn sign(data: Vec, keyvalue: Vec) -> Result, sign::Error> { 11 | let key = hmac::Key::new(hmac::HMAC_SHA256, &keyvalue); 12 | let signature = hmac::sign(&key, &data); 13 | let tag = HEXUPPER.encode(signature.as_ref()); 14 | Ok(tag.into_bytes()) 15 | } 16 | } 17 | 18 | impl verify::Guest for Component { 19 | fn verify(data: Vec, keyvalue: Vec, tag: Vec) -> bool { 20 | let key = hmac::Key::new(hmac::HMAC_SHA256, &keyvalue); 21 | let Ok(tag) = HEXUPPER.decode(tag.as_slice()) else { 22 | return false; 23 | }; 24 | match hmac::verify(&key, data.as_slice(), tag.as_slice()) { 25 | Ok(_) => true, 26 | _ => false, 27 | } 28 | } 29 | } 30 | bindings::export!(Component with_types_in bindings); 31 | -------------------------------------------------------------------------------- /signed-webhooks/hmac/wit/world.wit: -------------------------------------------------------------------------------- 1 | package fermyon:hmac@0.1.0; 2 | 3 | world signing { 4 | export types; 5 | export sign; 6 | export verify; 7 | } 8 | 9 | interface sign { 10 | use types.{error}; 11 | sign: func(data: list, keyvalue: list) -> result, error>; 12 | } 13 | 14 | interface verify { 15 | verify: func(data: list, keyvalue: list, tag: list) -> bool; 16 | } 17 | 18 | interface types { 19 | type error = string; 20 | } 21 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.wasm 3 | .spin 4 | .venv -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY build: 2 | build: 3 | source ./.venv/bin/activate; \ 4 | pip install -r requirements.txt; \ 5 | componentize-py -d wit -w verification componentize -m spin_sdk=spin-http app -o app.wasm; \ 6 | deactivate 7 | wasm-tools compose -d ./../hmac/target/wasm32-wasi/release/hmac.wasm ./app.wasm -o ./composed.wasm 8 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | 8 | [dev-packages] 9 | 10 | [requires] 11 | python_version = "3.10" 12 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/app.py: -------------------------------------------------------------------------------- 1 | from spin_sdk import http, key_value 2 | from spin_sdk.http import Request, Response 3 | from verification.imports.verify import verify 4 | from http_router import Router, exceptions 5 | 6 | from urllib.parse import ParseResult, urlparse, parse_qs 7 | import json 8 | 9 | router = Router(trim_last_slash=True) 10 | 11 | @router.route("/target", methods=["POST"]) 12 | def handle_inbound_webhook(uri: ParseResult, request: Request) -> Response: 13 | if uri.query == "handshake=true": 14 | return handle_handshake(request) 15 | else: 16 | return handle_invocation(request) 17 | 18 | def handle_invocation(request: Request) -> Response: 19 | tag = request.headers.get("x-signature") 20 | print("CONSUMER: Received tag ",tag) 21 | tag = bytes(tag, 'utf-8') 22 | with key_value.open_default() as store: 23 | keydata = store.get("signing-key-data") 24 | print("CONSUMER: Loaded key data from key-value store:", str(keydata)) 25 | print("CONSUMER: Verifying integrity of payload received from PRODUCER...") 26 | valid = verify(request.body, keydata, tag) 27 | print("-------------------") 28 | print("CONSUMER: Payload verification result:", bool(valid)) 29 | print("-------------------") 30 | if valid == False: 31 | print("CONSUMER: Responding with HTTP 400") 32 | return Response(400, {"content-type": "text/plain"}, None) 33 | print("CONSUMER: Responding with HTTP 200") 34 | return Response(200, {"content-type": "text/plain"}, bytes("Received payload and verified integrity", "utf-8")) 35 | 36 | def handle_handshake(request: Request) -> Response: 37 | j = json.loads(request.body.decode('utf-8')) 38 | keyData = j["keyData"] 39 | print("CONSUMER: Received",keyData,"upon registering for webhooks with PRODUCER.") 40 | with key_value.open_default() as store: 41 | store.set("signing-key-data", bytes(keyData, "utf-8")) 42 | print("CONSUMER: Stored key data in key value store") 43 | return Response(200, {"content-type": "text/plain"}, None) 44 | 45 | class IncomingHandler(http.IncomingHandler): 46 | def handle_request(self, request: Request) -> Response: 47 | 48 | uri = urlparse(request.uri) 49 | try: 50 | handler = router(uri.path, request.method) 51 | return handler.target(uri, request) 52 | except exceptions.NotFoundError: 53 | return Response(404, {}, None) -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/requirements.txt: -------------------------------------------------------------------------------- 1 | componentize-py==0.12.0 2 | http-router==4.1.2 3 | mypy==1.8.0 4 | mypy-extensions==1.0.0 5 | spin-sdk==2.0.0 6 | typing_extensions==4.10.0 7 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | authors = ["Fermyon Engineering "] 5 | description = "" 6 | name = "webhook-consumer" 7 | version = "0.1.0" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "webhook-consumer" 12 | 13 | [component.webhook-consumer] 14 | source = "composed.wasm" 15 | key_value_stores = ["default"] 16 | 17 | [component.webhook-consumer.build] 18 | command = "componentize-py -w spin-http componentize app -o app.wasm" 19 | watch = ["*.py", "requirements.txt"] 20 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/wit/deps/hmac/world.wit: -------------------------------------------------------------------------------- 1 | package fermyon:hmac@0.1.0; 2 | 3 | world signing { 4 | export sign; 5 | export types; 6 | export verify; 7 | } 8 | 9 | interface types { 10 | type error = string; 11 | } 12 | interface sign { 13 | use types.{error}; 14 | sign: func(data: list, keyvalue: list) -> result, error>; 15 | } 16 | 17 | interface verify { 18 | verify: func(data: list, keyvalue: list, tag: list) -> bool; 19 | } 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-consumer/wit/world.wit: -------------------------------------------------------------------------------- 1 | package fermyon:webhook-consumer@0.1.0; 2 | 3 | world verification { 4 | import fermyon:hmac/verify@0.1.0; 5 | } -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .spin/ 3 | *.wasm -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "webhook-producer" 3 | authors = ["Fermyon Engineering "] 4 | description = "" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | rand = "0.8.5" 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_json = "1.0" 16 | spin-sdk = "3.0.1" 17 | wit-bindgen = "0.24.0" 18 | wit-bindgen-rt = "0.24.0" 19 | 20 | [workspace] 21 | 22 | [package.metadata.component] 23 | package = "fermyon:webhooks-producer" 24 | 25 | [package.metadata.component.target.dependencies] 26 | "fermyon:hmac" = { path = "../hmac/wit" } 27 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY build: 2 | build: 3 | cargo component build --release 4 | wasm-tools compose -d ./../hmac/target/wasm32-wasip1/release/hmac.wasm ./target/wasm32-wasip1/release/webhook_producer.wasm -o ./composed.wasm -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/migrations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS REGISTRATIONS ( 2 | URL VARCHAR(1024) PRIMARY KEY, 3 | EVENT VARCHAR(50) NOT NULL, 4 | KEY VARCHAR(50) NOT NULL 5 | ) -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = 2 2 | 3 | [application] 4 | name = "webhook-producer" 5 | version = "0.1.0" 6 | authors = ["Fermyon Engineering "] 7 | description = "" 8 | 9 | [[trigger.http]] 10 | route = "/..." 11 | component = "webhook-producer" 12 | 13 | [component.webhook-producer] 14 | source = "target/wasm32-wasip1/release/composed.wasm" 15 | sqlite_databases = ["default"] 16 | allowed_outbound_hosts = ["http://localhost:3001", "http://localhost:3002"] 17 | 18 | [component.webhook-producer.build] 19 | command = "cargo component build --release && wasm-tools compose -d ./../hmac/target/wasm32-wasip1/release/hmac.wasm ./target/wasm32-wasip1/release/webhook_producer.wasm -o ./target/wasm32-wasip1/release/composed.wasm" 20 | watch = ["src/**/*.rs", "Cargo.toml"] 21 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/src/lib.rs: -------------------------------------------------------------------------------- 1 | use registrations::{ 2 | delete_all_registrations, get_all_registrations, register_webhook, Registration, 3 | }; 4 | use serde::Serialize; 5 | use spin_sdk::http::{IntoResponse, Method, Params, Request, Response, Router}; 6 | use spin_sdk::http_component; 7 | mod bindings; 8 | 9 | use spin_sdk::sqlite::Connection; 10 | use std::str; 11 | 12 | use crate::bindings::fermyon::hmac::sign::sign; 13 | mod registrations; 14 | 15 | #[derive(Debug, Serialize)] 16 | pub struct SamplePayload { 17 | pub event: String, 18 | pub data: String, 19 | } 20 | 21 | #[http_component] 22 | fn handle_simple_http_api(req: Request) -> anyhow::Result { 23 | let mut router = Router::default(); 24 | router.post_async("/registrations", register_webhook); 25 | router.get("/registrations", get_all_registrations); 26 | router.delete("/registrations", delete_all_registrations); 27 | router.post_async("/fire", demonstrate_firing); 28 | Ok(router.handle(req)) 29 | } 30 | 31 | async fn demonstrate_firing(_: Request, _: Params) -> anyhow::Result { 32 | let con = Connection::open_default()?; 33 | let res = con.execute("SELECT URL, EVENT, KEY FROM REGISTRATIONS", &[])?; 34 | println!("PRODUCER: Loading all CONSUMERS from database"); 35 | let registrations: Vec = res 36 | .rows() 37 | .into_iter() 38 | .map(|row| Registration { 39 | url: row.get::<&str>("URL").map(|v| v.to_string()).unwrap(), 40 | event: row.get::<&str>("EVENT").map(|v| v.to_string()).unwrap(), 41 | signing_key: row.get::<&str>("KEY").map(|v| v.to_string()).unwrap(), 42 | }) 43 | .collect(); 44 | for reg in registrations.into_iter() { 45 | let payload = serde_json::to_vec(&SamplePayload { 46 | event: reg.event, 47 | data: reg.url.clone(), 48 | })?; 49 | 50 | let signature = sign(&payload, ®.signing_key.as_bytes()) 51 | .map(|by| String::from_utf8(by).unwrap()) 52 | .unwrap(); 53 | println!( 54 | "PRODUCER: Sending signed payload to CONSUMER {}", 55 | reg.url.clone() 56 | ); 57 | let req = Request::builder() 58 | .method(Method::Post) 59 | .uri(reg.url.clone()) 60 | .header("X-Signature", signature) 61 | .body(payload) 62 | .build(); 63 | 64 | let response: Response = spin_sdk::http::send(req).await?; 65 | 66 | println!( 67 | "PRODUCER: CONSUMER {} responded with status {}", 68 | reg.url.clone(), 69 | response.status() 70 | ); 71 | } 72 | 73 | Ok(Response::builder().status(200).body(()).build()) 74 | } 75 | -------------------------------------------------------------------------------- /signed-webhooks/webhook-producer/wit/world.wit: -------------------------------------------------------------------------------- 1 | package fermyon:webhooks-producer@0.1.0; 2 | 3 | world signing { 4 | import fermyon:hmac/sign@0.1.0; 5 | } 6 | --------------------------------------------------------------------------------