├── .docker
├── rabbitmq
│ └── Dockerfile
└── rust
│ └── Dockerfile
├── .env.dist
├── .github
├── security.yml
└── workflows
│ ├── cargo_sort.yml
│ ├── conventional_commits.yml
│ └── rust.yml
├── .gitignore
├── .gitmodules
├── Cargo.lock
├── Cargo.toml
├── Dockerfile
├── LICENSE
├── README.md
├── assets
└── logo.jpeg
├── cli
├── Cargo.toml
└── src
│ └── main.rs
├── docker-compose.yml
├── examples
├── Cargo.toml
└── src
│ ├── destination-server.rs
│ └── producer-server.rs
├── justfile
├── scripts
└── init-db.sh
├── sdk
├── Cargo.toml
└── src
│ ├── application.rs
│ ├── client.rs
│ ├── endpoint.rs
│ ├── error.rs
│ ├── event.rs
│ └── lib.rs
└── server
├── Cargo.toml
├── migrations
├── 20240515163040_create_application_table.sql
├── 20240517110248_create_endpoint_table.sql
├── 20240523211340_create_events_table.sql
├── 20240526112326_create_message_table.sql
└── 20240531074624_create_attempt_log_table.sql
├── server.http
├── src
├── amqp.rs
├── app.rs
├── bin
│ ├── dispatcher.rs
│ └── server.rs
├── circuit_breaker.rs
├── cmd.rs
├── config.rs
├── configuration
│ ├── domain.rs
│ ├── handlers.rs
│ ├── mod.rs
│ ├── models.rs
│ └── storage.rs
├── dispatch_consumer.rs
├── error.rs
├── events
│ ├── domain.rs
│ ├── handlers.rs
│ ├── mod.rs
│ ├── models.rs
│ └── storage.rs
├── handlers
│ ├── health_check.rs
│ └── mod.rs
├── lib.rs
├── logs.rs
├── retry.rs
├── routes.rs
├── sender.rs
├── storage.rs
├── tests.rs
├── time.rs
└── types
│ └── mod.rs
└── tests
└── api
├── common.rs
├── create_application.rs
├── create_endpoint.rs
├── create_event.rs
├── endpoint_status.rs
├── health_check.rs
└── main.rs
/.docker/rabbitmq/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG RABBITMQ_VERSION=3.13.0
2 |
3 | FROM rabbitmq:${RABBITMQ_VERSION}-management-alpine
4 |
5 | RUN apk update && apk add curl
6 | RUN curl -L https://github.com/rabbitmq/rabbitmq-delayed-message-exchange/releases/download/v$RABBITMQ_VERSION/rabbitmq_delayed_message_exchange-$RABBITMQ_VERSION.ez > $RABBITMQ_HOME/plugins/rabbitmq_delayed_message_exchange-$RABBITMQ_VERSION.ez
7 | RUN chown rabbitmq:rabbitmq /plugins/rabbitmq_delayed_message_exchange-$RABBITMQ_VERSION.ez
8 | RUN rabbitmq-plugins enable rabbitmq_delayed_message_exchange
--------------------------------------------------------------------------------
/.docker/rust/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM rust:1.78-alpine3.20 as builder
2 |
3 | RUN apk add --no-cache \
4 | alpine-sdk \
5 | libressl-dev \
6 | bash
7 |
8 | RUN cargo install --version=0.7.4 sqlx-cli --no-default-features --features rustls,postgres
9 | RUN cargo install --version=0.1.48 cargo-udeps
10 | RUN cargo install --version=0.30.0 cargo-tarpaulin
11 | RUN cargo install --version=1.0.9 cargo-sort
12 |
13 | FROM rust:1.78-alpine3.20
14 |
15 | RUN apk update \
16 | && apk upgrade --available \
17 | && apk add --no-cache \
18 | alpine-sdk \
19 | libressl-dev \
20 | bash \
21 | gnupg
22 |
23 | RUN rm -rf /var/cache/apk/*
24 |
25 | COPY --from=builder /usr/local/cargo/bin/cargo-tarpaulin /usr/local/cargo/bin/cargo-tarpaulin
26 | COPY --from=builder /usr/local/cargo/bin/cargo-udeps /usr/local/cargo/bin/cargo-udeps
27 | COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/cargo/bin/sqlx
28 | COPY --from=builder /usr/local/cargo/bin/cargo-sort /usr/local/cargo/bin/cargo-sort
29 |
30 | RUN rustup component add clippy
31 |
--------------------------------------------------------------------------------
/.env.dist:
--------------------------------------------------------------------------------
1 | ## SERVER ##
2 | SERVER_PORT=8090
3 | SERVER_HOST=localhost
4 | SERVER_URL=http://${SERVER_HOST}:${SERVER_PORT}
5 |
6 | ## POSTGRES ##
7 | POSTGRES_HOST=postgres
8 | POSTGRES_PORT=5432
9 | POSTGRES_USER=webhooks
10 | POSTGRES_PASSWORD=webhooks
11 | POSTGRES_DB=webhooks
12 | DATABASE_URL=postgres://webhooks:webhooks@postgres:5432/webhooks
13 |
14 | ## AMQP ##
15 | AMQP_HOST=rabbitmq
16 | AMQP_PORT=5672
17 | AMQP_USER=guest
18 | AMQP_PASSWORD=guest
19 | AMQP_SENT_MESSAGE_QUEUE=sent-message
--------------------------------------------------------------------------------
/.github/security.yml:
--------------------------------------------------------------------------------
1 | name: Security audit
2 | on:
3 | schedule:
4 | - cron: '0 0 * * *'
5 | jobs:
6 | audit:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 | - uses: rustsec/audit-check@v1.4.1
11 | with:
12 | token: ${{ secrets.GITHUB_TOKEN }}
13 |
--------------------------------------------------------------------------------
/.github/workflows/cargo_sort.yml:
--------------------------------------------------------------------------------
1 | name: Cargo sort
2 | on:
3 | pull_request:
4 | paths:
5 | - 'Cargo.toml'
6 | - '**/Cargo.toml'
7 | push:
8 | branches:
9 | - master
10 | paths:
11 | - 'Cargo.toml'
12 | - '**/Cargo.toml'
13 | jobs:
14 | cargo_sort:
15 | runs-on: ubuntu-latest
16 | container:
17 | image: ghcr.io/manhunto/webhooks-rs-dev:latest
18 | credentials:
19 | username: manhunto
20 | password: ${{ secrets.GHCR_TOKEN }}
21 | steps:
22 | - uses: actions/checkout@v4
23 | - name: Cache setup
24 | uses: Swatinem/rust-cache@v2
25 | - name: Run cargo sort
26 | run: cargo sort --workspace --check
27 |
--------------------------------------------------------------------------------
/.github/workflows/conventional_commits.yml:
--------------------------------------------------------------------------------
1 | name: Conventional Commits
2 | on:
3 | push:
4 | branches:
5 | - master
6 | pull_request:
7 | jobs:
8 | build:
9 | name: Conventional Commits
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: webiny/action-conventional-commits@v1.3.0
14 | with:
15 | allowed-commit-types: "feat,fix,docs,style,refactor,test,build,perf,ci,chore,revert,merge,wip,ops"
16 |
--------------------------------------------------------------------------------
/.github/workflows/rust.yml:
--------------------------------------------------------------------------------
1 | name: Rust checks
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | env:
10 | CARGO_TERM_COLOR: always
11 | RUSTFLAGS: "-Dwarnings"
12 | NIGHTLY_VERSION: "nightly-2024-05-18"
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | container:
18 | image: ghcr.io/manhunto/webhooks-rs-dev:latest
19 | credentials:
20 | username: manhunto
21 | password: ${{ secrets.GHCR_TOKEN }}
22 | services:
23 | postgres:
24 | image: postgres:16.3-alpine3.20
25 | env:
26 | POSTGRES_USER: webhooks
27 | POSTGRES_PASSWORD: webhooks
28 | steps:
29 | - uses: actions/checkout@v4
30 | - name: Cache setup
31 | uses: Swatinem/rust-cache@v2
32 | - name: Init db
33 | run: ./scripts/init-db.sh
34 | - name: Build
35 | run: cargo build --all-targets --verbose
36 | tests:
37 | runs-on: ubuntu-latest
38 | container:
39 | image: ghcr.io/manhunto/webhooks-rs-dev:latest
40 | credentials:
41 | username: manhunto
42 | password: ${{ secrets.GHCR_TOKEN }}
43 | services:
44 | postgres:
45 | image: postgres:16.3-alpine3.20
46 | env:
47 | POSTGRES_USER: webhooks
48 | POSTGRES_PASSWORD: webhooks
49 | POSTGRES_DB: webhooks
50 | rabbitmq:
51 | image: ghcr.io/manhunto/webhooks-rs-rabbitmq:latest
52 | credentials:
53 | username: manhunto
54 | password: ${{ secrets.GHCR_TOKEN }}
55 | steps:
56 | - uses: actions/checkout@v4
57 | - name: Cache setup
58 | uses: Swatinem/rust-cache@v2
59 | - name: Init db
60 | run: ./scripts/init-db.sh
61 | - name: Run tests
62 | run: cargo test --workspace --verbose
63 | coverage:
64 | runs-on: ubuntu-latest
65 | container:
66 | image: ghcr.io/manhunto/webhooks-rs-dev:latest
67 | options: --security-opt seccomp=unconfined
68 | credentials:
69 | username: manhunto
70 | password: ${{ secrets.GHCR_TOKEN }}
71 | services:
72 | postgres:
73 | image: postgres:16.3-alpine3.20
74 | env:
75 | POSTGRES_USER: webhooks
76 | POSTGRES_PASSWORD: webhooks
77 | POSTGRES_DB: webhooks
78 | rabbitmq:
79 | image: ghcr.io/manhunto/webhooks-rs-rabbitmq:latest
80 | credentials:
81 | username: manhunto
82 | password: ${{ secrets.GHCR_TOKEN }}
83 | steps:
84 | - uses: actions/checkout@v4
85 | - name: Cache setup
86 | uses: Swatinem/rust-cache@v2
87 | - name: Init db
88 | run: ./scripts/init-db.sh
89 | - name: Install nightly toolchain
90 | uses: actions-rs/toolchain@v1
91 | with:
92 | toolchain: ${{ env.NIGHTLY_VERSION }}
93 | override: true
94 | - name: Generate code coverage
95 | run: cargo +${{ env.NIGHTLY_VERSION }} tarpaulin --verbose --all-features --workspace --ignore-tests --timeout 120 --out xml
96 | - name: Upload coverage reports to Codecov
97 | uses: codecov/codecov-action@v4.6.0
98 | with:
99 | fail_ci_if_error: true
100 | token: ${{ secrets.CODECOV_TOKEN }}
101 | verbose: true
102 | os: alpine
103 | - name: Archive code coverage results
104 | uses: actions/upload-artifact@v4
105 | with:
106 | name: code-coverage-report
107 | path: cobertura.xml
108 | clippy:
109 | runs-on: ubuntu-latest
110 | container:
111 | image: ghcr.io/manhunto/webhooks-rs-dev:latest
112 | credentials:
113 | username: manhunto
114 | password: ${{ secrets.GHCR_TOKEN }}
115 | services:
116 | postgres:
117 | image: postgres:16.3-alpine3.20
118 | env:
119 | POSTGRES_USER: webhooks
120 | POSTGRES_PASSWORD: webhooks
121 | POSTGRES_DB: webhooks
122 | steps:
123 | - uses: actions/checkout@v4
124 | - name: Cache setup
125 | uses: Swatinem/rust-cache@v2
126 | - name: Init db
127 | run: ./scripts/init-db.sh
128 | - name: Run Clippy
129 | run: cargo clippy --all-targets --all-features
130 | format:
131 | runs-on: ubuntu-latest
132 | steps:
133 | - uses: actions/checkout@v4
134 | - name: Run fmt
135 | run: cargo fmt --all --check
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /.idea
3 | .env
4 | /.sqlx
5 | build_rs_cov.profraw
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "docs"]
2 | path = docs
3 | url = git@github.com:manhunto/webhooks-rs.wiki.git
4 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | resolver = "2"
3 | members = ["cli", "examples", "sdk", "server"]
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM rust:1.78-alpine3.20 as builder
2 |
3 | RUN apk add --no-cache \
4 | alpine-sdk \
5 | libressl-dev \
6 | bash
7 |
8 | RUN cargo install --version=0.7.4 sqlx-cli --no-default-features --features rustls,postgres
9 | RUN cargo install --version=0.1.48 cargo-udeps
10 | RUN cargo install --version=0.30.0 cargo-tarpaulin
11 | RUN cargo install --version=1.0.9 cargo-sort
12 |
13 | FROM rust:1.78-alpine3.20
14 |
15 | RUN apk update \
16 | && apk upgrade --available \
17 | && apk add --no-cache \
18 | alpine-sdk \
19 | libressl-dev \
20 | bash \
21 | ca-certificates \
22 | gnupg
23 |
24 | RUN rm -rf /var/cache/apk/*
25 |
26 | COPY --from=builder /usr/local/cargo/bin/cargo-tarpaulin /usr/local/cargo/bin/cargo-tarpaulin
27 | COPY --from=builder /usr/local/cargo/bin/cargo-udeps /usr/local/cargo/bin/cargo-udeps
28 | COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/cargo/bin/sqlx
29 | COPY --from=builder /usr/local/cargo/bin/cargo-sort /usr/local/cargo/bin/cargo-sort
30 |
31 | RUN rustup component add clippy
32 |
33 | ADD .docker/cacert.pem /usr/local/share/ca-certificates/my.crt
34 |
35 | RUN update-ca-certificates
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Jakub Sładek
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!CAUTION]
2 | > The project is not finished, it is not stable and it is constantly being developed.
3 |
4 | # webhooks-rs
5 |
6 |
7 |

8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ## ℹ️ About
19 |
20 | **webhooks-rs** is a project for sending webhooks using the http protocol. The main goals and objectives are to create
21 | an application that is high-performing, configurable and scalable.
22 |
23 | >
24 | > \[!NOTE]
25 | >
26 | > This project takes part and was created thanks to the [100 Commits](https://100commitow.pl/) challenge and is my first
27 | > significant project written in Rust.
28 |
29 | ### MVP features
30 |
31 | - [x] Retry policy for failed messages
32 | - [x] Endpoint can be disabled manually
33 | - [x] Circuit breaker
34 | - [x] Persistence
35 | - [x] SDK Beta
36 | - [x] CLI Beta
37 | - [x] Documentation
38 | - [x] Integration tests
39 | - [x] Error handling and validation (as POC)
40 |
41 | ### Roadmap
42 |
43 | - [ ] Release sdk as crate and bins (with GitHub action)
44 | - [ ] Sem ver
45 | - [ ] Rate-limit
46 | - [ ] Auth
47 | - [ ] Signed webhooks - server can verify that message was sent from valid server
48 | - [ ] Distributed architecture
49 | - [ ] Data retention
50 | - [ ] Logging and monitoring
51 | - [ ] Dockerized
52 |
53 | ## 📚 Domain explanation
54 |
55 | **Application** - Is a container that groups endpoints. In a multi-tenant architecture, it can be a separate tenant.
56 | Each application can have a separate configuration and secrets (in progress...).
57 |
58 | **Endpoint** - This is the url of the server to which messages are sent. Each endpoint can be deactivated individually -
59 | either manually or automatically by the circuit breaker. Endpoint can be only in one application.
60 |
61 | **Event** - This is an event that originated in your system. The event has a topic and a payload. For now, it only
62 | supports JSON payload.
63 |
64 | **Message** - In a nutshell, it can be said to be an event for a given endpoint. A given event can be distributed to
65 | several endpoints.
66 |
67 | **Attempt** - This is a log of attempts to deliver a particular message. A given message may have multiple delivery
68 | attempts (e.g. endpoint is temporarily unavailable and message had to be retried by retry policy).
69 |
70 | ## ⚙️ How to use?
71 |
72 | ### Server
73 |
74 | Before run environment by using `just init`. This command run a docker and execute migrations. Server is split into two
75 | parts - server and dispatcher. Run `just rs` and `just rd`.
76 |
77 | Server has rest api interface. Example commands you can find in `server/server.http`. Please familiarise oneself
78 | with [Domain Explanation](#domain-explanation)
79 |
80 | ### SDK
81 |
82 | > \[!IMPORTANT]
83 | >
84 | > SKD requires running server and dispatcher. See [Server](#server) section.
85 |
86 | You can find an example of the use of the sdk in the [examples/src/producer-server.rs](examples/src/producer-server.rs)
87 |
88 | ### Cli
89 |
90 | > \[!IMPORTANT]
91 | >
92 | > Cli requires running server and dispatcher. See [Server](#server) section.
93 |
94 | To explore all possibilities run `cargo run --package=cli`. Cli is divided by resources sections.
95 |
96 | #### Create application
97 |
98 | ```shell
99 | $ cargo run --package=cli application create "example application"
100 | App app_2hV5JuBgjMAQlDNNbepHTFnkicy with name 'example application' has been created
101 | ```
102 |
103 | #### Create endpoint
104 |
105 | To create an endpoint in a recently created application
106 |
107 | ```shell
108 | $ cargo run --package=cli endpoint create app_2hV5JuBgjMAQlDNNbepHTFnkicy http://localhost:8090/ contact.created,contact.updated
109 | Endpoint ep_2hV67JEIXUvFCN4bv43TUXVmX0s has been created
110 | ```
111 |
112 | #### Create event
113 |
114 | ```shell
115 | $ cargo run --package=cli event create app_2hV5JuBgjMAQlDNNbepHTFnkicy contact.created '{"foo":"bar"}'
116 | Event evt_2hV6UoIY9p6YnLmiawSvh4nh4Uf has been created
117 | ```
118 |
119 | ## 👨💻 Development
120 |
121 | ### Prerequisites
122 |
123 | - **[just](https://github.com/casey/just)** - optional, if you want to run raw commands
124 | - **[docker with docker-compose](https://www.docker.com/products/docker-desktop/)** - optional, if you want to set up
125 | the environment on your own
126 |
127 | ### Troubleshoots
128 |
129 | #### 1. "Too many open files" during running tests
130 |
131 | ```
132 | called `Result::unwrap()` on an `Err` value: Os { code: 24, kind: Uncategorized, message: "Too many open files" }
133 | ```
134 |
135 | Execute (on linux/mac os) `ulimit -n 10000` (default is 1024)
136 |
137 | ## 🤝 Contribution
138 |
139 | If you want to contribute to the growth of this project, please follow
140 | the [conventional commits](https://www.conventionalcommits.org/) in your pull requests.
141 |
--------------------------------------------------------------------------------
/assets/logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/manhunto/webhooks-rs/ad94b8b927ef9a6acab11969c3025c74538b8a5f/assets/logo.jpeg
--------------------------------------------------------------------------------
/cli/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "cli"
3 | version = "0.1.0"
4 | edition = "2021"
5 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
6 |
7 | [dependencies]
8 | anyhow = "1.0.93"
9 | clap = { version = "4.5.20", features = ["derive"] }
10 | dotenv = "0.15.0"
11 | sdk = { path = "../sdk" }
12 | serde_json = "1.0.132"
13 | tokio = { version = "1.41.1", features = ["full"] }
14 |
--------------------------------------------------------------------------------
/cli/src/main.rs:
--------------------------------------------------------------------------------
1 | use std::env;
2 |
3 | use clap::{Parser, Subcommand};
4 | use dotenv::dotenv;
5 | use serde_json::Value;
6 |
7 | use sdk::WebhooksSDK;
8 |
9 | /// Cli app to manage webhook-rs server
10 | #[derive(Debug, Parser, PartialEq)]
11 | #[clap(name = "webhooks-cli", version, about)]
12 | pub struct Cli {
13 | #[clap(subcommand)]
14 | command: Command,
15 | }
16 |
17 | #[derive(Clone, Debug, Subcommand, PartialEq)]
18 | enum Command {
19 | /// Resource for application management
20 | Application {
21 | #[clap(subcommand)]
22 | subcommand: ApplicationSubcommand,
23 | },
24 | /// Resource for endpoints management
25 | Endpoint {
26 | #[clap(subcommand)]
27 | subcommand: EndpointSubcommand,
28 | },
29 | /// Resource for events management
30 | Event {
31 | #[clap(subcommand)]
32 | subcommand: EventSubcommand,
33 | },
34 | }
35 |
36 | #[derive(Clone, Debug, Subcommand, PartialEq)]
37 | enum ApplicationSubcommand {
38 | /// Creates an application
39 | Create {
40 | /// Application name
41 | name: String,
42 | },
43 | }
44 |
45 | #[derive(Clone, Debug, Subcommand, PartialEq)]
46 | enum EndpointSubcommand {
47 | /// Creates an endpoint
48 | Create {
49 | app_id: String,
50 | url: String,
51 | #[arg(value_parser, num_args = 1.., value_delimiter = ',', required = true)]
52 | topics: Vec,
53 | },
54 | }
55 |
56 | #[derive(Clone, Debug, Subcommand, PartialEq)]
57 | enum EventSubcommand {
58 | Create {
59 | app_id: String,
60 | topic: String,
61 | #[arg(help = "JSON payload", value_parser(parse_json_value))]
62 | payload: Value,
63 | },
64 | }
65 |
66 | fn parse_json_value(val: &str) -> Result {
67 | let payload = serde_json::from_str(val).map_err(|e| e.to_string())?;
68 |
69 | Ok(payload)
70 | }
71 |
72 | #[tokio::main]
73 | async fn main() -> anyhow::Result<()> {
74 | dotenv().ok();
75 |
76 | let cli = Cli::parse();
77 | let url = env::var("SERVER_URL").expect("env SERVER_URL is not set");
78 | let sdk = WebhooksSDK::new(&url);
79 |
80 | match cli.command {
81 | Command::Application { subcommand } => match subcommand {
82 | ApplicationSubcommand::Create { name } => {
83 | let app = sdk.application().create(name.as_str()).await?;
84 |
85 | println!("App {} with name '{}' has been created", app.id, app.name);
86 | }
87 | },
88 | Command::Endpoint { subcommand } => match subcommand {
89 | EndpointSubcommand::Create {
90 | app_id,
91 | url,
92 | topics,
93 | } => {
94 | let topics_str = topics.iter().map(|s| s.as_str()).collect();
95 | let endpoint = sdk.endpoints().create(&app_id, &url, topics_str).await?;
96 |
97 | println!("Endpoint {} has been created", endpoint.id);
98 | }
99 | },
100 | Command::Event { subcommand } => match subcommand {
101 | EventSubcommand::Create {
102 | app_id,
103 | topic,
104 | payload,
105 | } => {
106 | let event = sdk.events().create(&app_id, &topic, &payload).await?;
107 |
108 | println!("Event {} has been created", event.id);
109 | }
110 | },
111 | };
112 |
113 | Ok(())
114 | }
115 |
116 | #[cfg(test)]
117 | mod test {
118 | use clap::error::ErrorKind::MissingRequiredArgument;
119 | use clap::{CommandFactory, Parser};
120 | use serde_json::json;
121 |
122 | use crate::Command::{Endpoint, Event};
123 | use crate::{Cli, EndpointSubcommand, EventSubcommand};
124 |
125 | #[test]
126 | fn verify_cli() {
127 | Cli::command().debug_assert()
128 | }
129 |
130 | #[test]
131 | fn endpoint_create_topics_cannot_be_empty() {
132 | let result = Cli::try_parse_from([
133 | "webhooks-cli",
134 | "endpoint",
135 | "create",
136 | "app_2hRzcGs8D5aLaHBWHyqIcibuFA1",
137 | "http://localhost:8080",
138 | ]);
139 |
140 | assert!(result.is_err());
141 | assert_eq!(MissingRequiredArgument, result.err().unwrap().kind());
142 | }
143 |
144 | #[test]
145 | fn endpoint_create_single_topic() {
146 | let result = Cli::try_parse_from([
147 | "webhooks-cli",
148 | "endpoint",
149 | "create",
150 | "app_2hRzcGs8D5aLaHBWHyqIcibuFA1",
151 | "http://localhost:8080",
152 | "contact.created",
153 | ]);
154 |
155 | let expected = Cli {
156 | command: Endpoint {
157 | subcommand: EndpointSubcommand::Create {
158 | app_id: "app_2hRzcGs8D5aLaHBWHyqIcibuFA1".to_string(),
159 | url: "http://localhost:8080".to_string(),
160 | topics: vec!["contact.created".to_string()],
161 | },
162 | },
163 | };
164 |
165 | assert!(result.is_ok());
166 | assert_eq!(expected, result.unwrap());
167 | }
168 |
169 | #[test]
170 | fn endpoint_create_multiple_topics() {
171 | let result = Cli::try_parse_from([
172 | "webhooks-cli",
173 | "endpoint",
174 | "create",
175 | "app_2hRzcGs8D5aLaHBWHyqIcibuFA1",
176 | "http://localhost:8080",
177 | "contact.created,contact.updated,contact.deleted",
178 | ]);
179 |
180 | let expected = Cli {
181 | command: Endpoint {
182 | subcommand: EndpointSubcommand::Create {
183 | app_id: "app_2hRzcGs8D5aLaHBWHyqIcibuFA1".to_string(),
184 | url: "http://localhost:8080".to_string(),
185 | topics: vec![
186 | "contact.created".to_string(),
187 | "contact.updated".to_string(),
188 | "contact.deleted".to_string(),
189 | ],
190 | },
191 | },
192 | };
193 |
194 | assert!(result.is_ok());
195 | assert_eq!(expected, result.unwrap());
196 | }
197 |
198 | #[test]
199 | fn event_create_handle_json() {
200 | let result = Cli::try_parse_from([
201 | "webhooks-cli",
202 | "event",
203 | "create",
204 | "app_2hRzcGs8D5aLaHBWHyqIcibuFA1",
205 | "contact.created",
206 | "{\"foo\":{\"bar\":\"baz\"}}",
207 | ]);
208 |
209 | let expected = Cli {
210 | command: Event {
211 | subcommand: EventSubcommand::Create {
212 | app_id: "app_2hRzcGs8D5aLaHBWHyqIcibuFA1".to_string(),
213 | topic: "contact.created".to_string(),
214 | payload: json!({
215 | "foo": {
216 | "bar" : "baz"
217 | }
218 | }),
219 | },
220 | },
221 | };
222 |
223 | assert!(result.is_ok());
224 | assert_eq!(expected, result.unwrap());
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 | services:
3 | rabbitmq:
4 | container_name: rabbitmq
5 | build:
6 | context: .
7 | dockerfile: .docker/rabbitmq/Dockerfile
8 | ports:
9 | - "5672:5672"
10 | - "15672:15672"
11 | privileged: true
12 | networks:
13 | - rabbitmq
14 | postgres:
15 | container_name: postgres
16 | image: postgres:16.3-alpine3.20
17 | ports:
18 | - "5432:5432"
19 | env_file:
20 | - .env
21 | networks:
22 | rabbitmq:
23 | driver: bridge
24 |
--------------------------------------------------------------------------------
/examples/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "webhooks_examples"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | [[example]]
8 | name = "destination-server"
9 | path = "src/destination-server.rs"
10 |
11 | [[example]]
12 | name = "producer-server"
13 | path = "src/producer-server.rs"
14 |
15 | [dependencies]
16 | actix-web = "4.9.0"
17 | dotenv = "0.15.0"
18 | futures = "0.3.31"
19 | rand = "0.8.5"
20 | sdk = { path = "../sdk" }
21 | serde_json = "1.0.132"
22 | tokio = { version = "1.41.1", features = ["full"] }
23 |
--------------------------------------------------------------------------------
/examples/src/destination-server.rs:
--------------------------------------------------------------------------------
1 | use actix_web::rt::time::sleep;
2 | use actix_web::web::Payload;
3 | use actix_web::{web, App, HttpResponse, HttpServer, Responder};
4 | use futures::StreamExt;
5 | use rand::Rng;
6 | use std::time::Duration;
7 | use web::BytesMut;
8 |
9 | async fn index(payload: Payload) -> impl Responder {
10 | let mut rng = rand::thread_rng();
11 | let delay = rng.gen_range(40..=300);
12 |
13 | let body = get_body(payload).await;
14 |
15 | println!("Request. Delay: {} ms :: Body: {}", delay, body,);
16 |
17 | sleep(Duration::from_millis(delay)).await;
18 |
19 | HttpResponse::NoContent()
20 | }
21 |
22 | async fn get_body(mut payload: Payload) -> String {
23 | let mut bytes = BytesMut::new();
24 | while let Some(item) = payload.next().await {
25 | let item = item.unwrap();
26 | bytes.extend_from_slice(&item);
27 | }
28 |
29 | String::from_utf8_lossy(&bytes).to_string()
30 | }
31 |
32 | #[actix_web::main]
33 | async fn main() -> std::io::Result<()> {
34 | let ip = "127.0.0.1";
35 | let port = 8080;
36 |
37 | println!("Server is listening for requests on {}:{}", ip, port);
38 |
39 | HttpServer::new(|| App::new().route("/", web::post().to(index)))
40 | .bind((ip, port))?
41 | .run()
42 | .await
43 | }
44 |
--------------------------------------------------------------------------------
/examples/src/producer-server.rs:
--------------------------------------------------------------------------------
1 | use std::env;
2 |
3 | use dotenv::dotenv;
4 | use serde_json::json;
5 |
6 | use sdk::error::Error;
7 | use sdk::WebhooksSDK;
8 |
9 | #[tokio::main]
10 | async fn main() -> Result<(), Error> {
11 | dotenv().ok();
12 |
13 | let url: String = env::var("SERVER_URL").unwrap();
14 |
15 | println!("{}", url);
16 |
17 | let sdk = WebhooksSDK::new(url.as_str());
18 | let app = sdk.application().create("dummy").await?;
19 |
20 | println!("App created - {:?}", app);
21 |
22 | let topic = "contact.created";
23 | let endpoint = sdk
24 | .endpoints()
25 | .create(&app.id, "http://localhost:8080", vec![topic])
26 | .await?;
27 |
28 | println!("Endpoint created - {:?}", endpoint);
29 |
30 | let payload = json!({
31 | "foo": {
32 | "bar": "baz"
33 | }
34 | });
35 |
36 | let event = sdk.events().create(&app.id, topic, &payload).await?;
37 |
38 | println!("Event created - {:?}", event);
39 |
40 | Ok(())
41 | }
42 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | rust-dev-name := "ghcr.io/manhunto/webhooks-rs-dev"
2 | rust-dev-version := "latest"
3 | rust-dev-image := rust-dev-name + ":" + rust-dev-version
4 |
5 | rabbitmq-dev-name := "ghcr.io/manhunto/webhooks-rs-rabbitmq"
6 | rabbitmq-dev-version := "latest"
7 | rabbitmq-dev-image := rabbitmq-dev-name + ":" + rabbitmq-dev-version
8 |
9 | alias b := build
10 | alias f := format
11 | alias fmt := format
12 | alias c := clippy
13 | alias t := test
14 | alias rs := run-server
15 | alias rd := run-dispatcher
16 | alias rps := run-producer-server
17 | alias rds := run-destination-server
18 | alias du := docker-up
19 | alias dd := docker-down
20 | alias rdb := rust-dev-build
21 | alias rdp := rust-dev-push
22 |
23 | default:
24 | @just --list
25 |
26 | build *OPTIONS:
27 | cargo build --all-targets --workspace {{ OPTIONS }}
28 |
29 | format:
30 | cargo fmt --all
31 |
32 | # Run main server
33 | run-server *OPTIONS:
34 | cargo run --package=server {{ OPTIONS }}
35 |
36 | # Run consumer that sends messages to destination servers
37 | run-dispatcher *OPTIONS:
38 | cargo run --package=server --bin=dispatcher {{ OPTIONS }}
39 |
40 | # Run example server that produces messages
41 | run-producer-server *OPTIONS:
42 | cargo run --example producer-server {{ OPTIONS }}
43 |
44 | # Run example server that listens for messages and act like real server (with random response delay)
45 | run-destination-server *OPTIONS:
46 | cargo run --example destination-server {{ OPTIONS }}
47 |
48 | # Run cli with args
49 | run-cli *ARGS:
50 | cargo run --package=cli -- {{ ARGS }}
51 |
52 | test:
53 | cargo test --workspace
54 |
55 | clippy:
56 | cargo clippy --all-targets --all-features -- -D warnings
57 |
58 | clippy-pedantic:
59 | cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic
60 |
61 | udeps:
62 | cargo +nightly udeps --all-targets
63 |
64 | coverage:
65 | cargo +nightly-2024-05-18 tarpaulin --all-features --workspace --ignore-tests --timeout 120
66 |
67 | docker-up *OPTIONS:
68 | docker compose --env-file=.env up {{ OPTIONS }}
69 |
70 | docker-down:
71 | docker compose down --remove-orphans
72 |
73 | check:
74 | just build
75 | cargo fmt --check --all
76 | just clippy
77 | just test
78 | cargo sort --workspace
79 |
80 | init:
81 | just docker-up --detach
82 | ./scripts/init-db.sh
83 |
84 | rust-dev-build:
85 | docker build --platform linux/amd64 . -t {{ rust-dev-image }} -f .docker/rust/Dockerfile
86 |
87 | rust-dev-push:
88 | docker push {{ rust-dev-image }}
89 |
90 | rabbitmq-dev-build:
91 | docker build --platform linux/amd64 . -t {{ rabbitmq-dev-image }} -f .docker/rabbitmq/Dockerfile
92 |
93 | rabbitmq-dev-push:
94 | docker push {{ rabbitmq-dev-image }}
95 |
96 | create-migration NAME:
97 | sqlx migrate add --source=server/migrations "{{ NAME }}"
--------------------------------------------------------------------------------
/scripts/init-db.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | cp -n .env.dist .env
4 |
5 | source .env
6 |
7 | echo "Run migrations";
8 |
9 | RETRIES=30
10 | until sqlx migrate run --source=server/migrations || [ $RETRIES -eq 0 ];
11 | do
12 | echo "Waiting for postgres server, $((RETRIES--)) remaining attempts..."
13 | sleep 1;
14 | done
--------------------------------------------------------------------------------
/sdk/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "sdk"
3 | version = "0.1.0"
4 | edition = "2021"
5 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
6 |
7 | [dependencies]
8 | reqwest = { version = "0.12.9", features = ["json"] }
9 | serde = { version = "1.0.214", features = ["derive"] }
10 | serde_json = "1.0.132"
11 | thiserror = "2.0.0"
12 | tokio = { version = "1.41.1", features = ["full"] }
13 | url = "2.5.2"
14 |
15 | [dev-dependencies]
16 | mockito = "1.5.0"
17 |
--------------------------------------------------------------------------------
/sdk/src/application.rs:
--------------------------------------------------------------------------------
1 | use std::str::FromStr;
2 |
3 | use serde::Deserialize;
4 | use serde_json::json;
5 |
6 | use crate::client::{Client, EndpointUrl};
7 | use crate::error::Error;
8 |
9 | #[derive(Deserialize, Debug, PartialEq)]
10 | pub struct Application {
11 | pub id: String,
12 | pub name: String,
13 | }
14 |
15 | pub struct ApplicationApi {
16 | client: Client,
17 | }
18 |
19 | impl ApplicationApi {
20 | #[must_use]
21 | pub fn new(client: Client) -> Self {
22 | Self { client }
23 | }
24 |
25 | pub async fn create(&self, name: &str) -> Result {
26 | let body = json!({
27 | "name": name,
28 | });
29 |
30 | self.client
31 | .post(EndpointUrl::from_str("application").unwrap(), body)
32 | .await
33 | }
34 | }
35 |
36 | #[cfg(test)]
37 | mod tests {
38 | use mockito::Matcher::Json;
39 | use serde_json::json;
40 |
41 | use crate::application::Application;
42 | use crate::error::Error;
43 | use crate::WebhooksSDK;
44 |
45 | #[tokio::test]
46 | async fn create_application() {
47 | let mut server = mockito::Server::new_async().await;
48 | let url = server.url();
49 |
50 | let mock = server
51 | .mock("POST", "/application")
52 | .match_body(Json(json!({"name": "dummy application"})))
53 | .with_body(r#"{"id":"app_2dSZgxc6qw0vR7hwZVXDJFleRXj","name":"dummy application"}"#)
54 | .with_header("content-type", "application/json")
55 | .with_status(201)
56 | .create_async()
57 | .await;
58 |
59 | let app = WebhooksSDK::new(url.as_str())
60 | .application()
61 | .create("dummy application")
62 | .await
63 | .unwrap();
64 |
65 | mock.assert_async().await;
66 |
67 | assert_eq!(
68 | Application {
69 | id: "app_2dSZgxc6qw0vR7hwZVXDJFleRXj".to_string(),
70 | name: "dummy application".to_string(),
71 | },
72 | app
73 | );
74 | }
75 |
76 | #[tokio::test]
77 | async fn can_handle_bad_request() {
78 | let mut server = mockito::Server::new_async().await;
79 | let url = server.url();
80 |
81 | server
82 | .mock("POST", "/application")
83 | .match_body(Json(json!({"name": ""})))
84 | .with_body(r#"{"error":"Validation error","messages":["Name cannot be empty"]}"#)
85 | .with_header("content-type", "application/json")
86 | .with_status(400)
87 | .create_async()
88 | .await;
89 |
90 | let error = WebhooksSDK::new(url.as_str())
91 | .application()
92 | .create("")
93 | .await
94 | .err()
95 | .unwrap();
96 |
97 | match error {
98 | Error::Reqwest(req) => panic!("is reqwest error {}", req),
99 | Error::Unknown => panic!("is unknown error"),
100 | Error::BadRequest(br) => {
101 | assert_eq!("Validation error", br.error());
102 | assert_eq!(vec!["Name cannot be empty"], br.messages());
103 | }
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/sdk/src/client.rs:
--------------------------------------------------------------------------------
1 | use std::path::PathBuf;
2 | use std::str::FromStr;
3 |
4 | use reqwest::header;
5 | use reqwest::header::USER_AGENT;
6 | use serde::de::DeserializeOwned;
7 | use serde::Serialize;
8 | use url::Url;
9 |
10 | use crate::error::Error;
11 | use crate::error::Error::BadRequest;
12 |
13 | #[derive(Clone)]
14 | pub struct Client {
15 | base_url: Url,
16 | client: reqwest::Client,
17 | }
18 |
19 | impl Client {
20 | pub fn new(api_url: Url) -> Self {
21 | Self {
22 | base_url: api_url,
23 | client: Self::client(),
24 | }
25 | }
26 |
27 | pub async fn post(&self, endpoint: EndpointUrl, body: I) -> Result
28 | where
29 | I: Serialize,
30 | O: DeserializeOwned,
31 | {
32 | let url = self.url(endpoint);
33 | let response = self.client.post(url).json(&body).send().await?;
34 |
35 | if 400 == response.status().as_u16() {
36 | let result = response.json::().await?;
37 |
38 | return Err(BadRequest(result));
39 | }
40 |
41 | Ok(response.json::().await?)
42 | }
43 |
44 | fn url(&self, endpoint: EndpointUrl) -> Url {
45 | self.base_url.join(endpoint.as_str()).unwrap_or_else(|_| {
46 | panic!(
47 | "Could not join strings to create endpoint url: '{}', '{}'",
48 | self.base_url,
49 | endpoint.as_str()
50 | )
51 | })
52 | }
53 |
54 | fn client() -> reqwest::Client {
55 | let mut headers = header::HeaderMap::new();
56 | let sdk_version = env!("CARGO_PKG_VERSION");
57 |
58 | headers.insert(
59 | USER_AGENT,
60 | header::HeaderValue::from_str(
61 | format!("webhooks-rs rust sdk v{}", sdk_version).as_str(),
62 | )
63 | .unwrap(),
64 | );
65 |
66 | reqwest::Client::builder()
67 | .default_headers(headers)
68 | .build()
69 | .unwrap()
70 | }
71 | }
72 |
73 | #[derive(Debug)]
74 | pub struct EndpointUrl {
75 | path: PathBuf, // fixme: it won't work on windows
76 | }
77 |
78 | impl EndpointUrl {
79 | #[must_use]
80 | pub fn new(path: String) -> Self {
81 | let path_buf = PathBuf::from(path);
82 |
83 | Self { path: path_buf }
84 | }
85 |
86 | fn as_str(&self) -> &str {
87 | self.path.to_str().unwrap()
88 | }
89 | }
90 |
91 | impl FromStr for EndpointUrl {
92 | type Err = Self;
93 |
94 | fn from_str(s: &str) -> Result {
95 | Ok(Self::new(s.to_string()))
96 | }
97 | }
98 |
99 | impl TryFrom for EndpointUrl {
100 | type Error = Self;
101 |
102 | fn try_from(value: String) -> Result {
103 | Ok(Self::new(value))
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/sdk/src/endpoint.rs:
--------------------------------------------------------------------------------
1 | use serde::Deserialize;
2 | use serde_json::json;
3 |
4 | use crate::client::{Client, EndpointUrl};
5 | use crate::error::Error;
6 |
7 | #[derive(Deserialize, Debug, PartialEq)]
8 | pub struct Endpoint {
9 | pub id: String,
10 | pub app_id: String,
11 | pub url: String,
12 | pub topics: Vec,
13 | }
14 |
15 | pub struct EndpointApi {
16 | client: Client,
17 | }
18 |
19 | impl EndpointApi {
20 | #[must_use]
21 | pub fn new(client: Client) -> Self {
22 | Self { client }
23 | }
24 |
25 | pub async fn create(
26 | &self,
27 | app_id: &str,
28 | url: &str,
29 | topics: Vec<&str>,
30 | ) -> Result {
31 | let body = json!({
32 | "url": url,
33 | "topics": topics
34 | });
35 |
36 | self.client
37 | .post(
38 | EndpointUrl::try_from(format!("application/{}/endpoint", app_id)).unwrap(),
39 | body,
40 | )
41 | .await
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/sdk/src/error.rs:
--------------------------------------------------------------------------------
1 | use std::fmt::{Display, Formatter};
2 |
3 | use serde::Deserialize;
4 | use thiserror::Error;
5 |
6 | use crate::error::Error::Reqwest;
7 |
8 | #[derive(Debug, Error)]
9 | pub enum Error {
10 | #[error("Error occurred during request: {0}")]
11 | Reqwest(reqwest::Error),
12 | #[error("Unknown error")]
13 | Unknown,
14 | #[error("Bad request: {0}")]
15 | BadRequest(BadRequest),
16 | }
17 |
18 | impl From for Error {
19 | fn from(value: reqwest::Error) -> Self {
20 | Reqwest(value)
21 | }
22 | }
23 |
24 | #[derive(Deserialize, Debug)]
25 | pub struct BadRequest {
26 | error: String,
27 | messages: Vec,
28 | }
29 |
30 | impl BadRequest {
31 | pub fn error(&self) -> String {
32 | self.error.clone()
33 | }
34 |
35 | pub fn messages(&self) -> Vec {
36 | self.messages.clone()
37 | }
38 | }
39 |
40 | impl Display for BadRequest {
41 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
42 | write!(f, "error: {}, messages: {:?}", self.error(), self.messages)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/sdk/src/event.rs:
--------------------------------------------------------------------------------
1 | use serde::Deserialize;
2 | use serde_json::{json, Value};
3 |
4 | use crate::client::{Client, EndpointUrl};
5 | use crate::error::Error;
6 |
7 | #[derive(Deserialize, Debug, PartialEq)]
8 | pub struct CreateEventResponse {
9 | pub id: String,
10 | }
11 |
12 | pub struct EventsApi {
13 | client: Client,
14 | }
15 |
16 | impl EventsApi {
17 | #[must_use]
18 | pub fn new(client: Client) -> Self {
19 | Self { client }
20 | }
21 |
22 | pub async fn create(
23 | &self,
24 | app_id: &str,
25 | topic: &str,
26 | payload: &Value,
27 | ) -> Result {
28 | let body = json!({
29 | "topic": topic,
30 | "payload": payload
31 | });
32 |
33 | self.client
34 | .post(
35 | EndpointUrl::try_from(format!("application/{}/event", app_id)).unwrap(),
36 | body,
37 | )
38 | .await
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/sdk/src/lib.rs:
--------------------------------------------------------------------------------
1 | use url::Url;
2 |
3 | use client::Client;
4 |
5 | use crate::application::ApplicationApi;
6 | use crate::endpoint::EndpointApi;
7 | use crate::event::EventsApi;
8 |
9 | mod application;
10 | mod client;
11 | mod endpoint;
12 | pub mod error;
13 | mod event;
14 |
15 | pub struct WebhooksSDK {
16 | client: Client,
17 | }
18 |
19 | impl WebhooksSDK {
20 | pub fn new(api_url: &str) -> Self {
21 | let url = Url::parse(api_url).unwrap();
22 |
23 | Self {
24 | client: Client::new(url),
25 | }
26 | }
27 |
28 | pub fn application(&self) -> ApplicationApi {
29 | ApplicationApi::new(self.client.clone())
30 | }
31 |
32 | pub fn endpoints(&self) -> EndpointApi {
33 | EndpointApi::new(self.client.clone())
34 | }
35 |
36 | pub fn events(&self) -> EventsApi {
37 | EventsApi::new(self.client.clone())
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/server/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "server"
3 | version = "0.1.0"
4 | edition = "2021"
5 | default-run = "server"
6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 |
8 | [dependencies]
9 | actix-web = "4.9.0"
10 | chrono = { version = "0.4.38", features = ["serde"] }
11 | dotenv = "0.15.0"
12 | envconfig = "0.11.0"
13 | futures = "0.3.31"
14 | futures-lite = "2.4.0"
15 | itertools = "0.13.0"
16 | lapin = "2.5.0"
17 | lazy_static = "1.5.0"
18 | log = "0.4.22"
19 | log4rs = "1.3.0"
20 | rand = "0.8.5"
21 | regex = "1.11.1"
22 | reqwest = { version = "0.12.9", features = ["json"] }
23 | serde = { version = "1.0.214", features = ["derive"] }
24 | serde_json = { version = "1.0.132", features = ["raw_value"] }
25 | sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] }
26 | svix-ksuid = { version = "^0.8.0", features = ["serde"] }
27 | tokio = { version = "1.41.1", features = ["full"] }
28 | url = "2.5.2"
29 | validator = { version = "0.19.0", features = ["derive"] }
30 |
31 | [dev-dependencies]
32 | fake = "3.0.0"
33 | mockito = "1.5.0"
34 | test-case = "3.3.1"
35 |
36 | [[bin]]
37 | name = "server"
38 | path = "src/bin/server.rs"
39 |
40 | [[bin]]
41 | name = "dispatcher"
42 | path = "src/bin/dispatcher.rs"
43 |
--------------------------------------------------------------------------------
/server/migrations/20240515163040_create_application_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE applications
2 | (
3 | id char(27) not null,
4 | primary key (id),
5 | name TEXT NOT NULL
6 | );
--------------------------------------------------------------------------------
/server/migrations/20240517110248_create_endpoint_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE endpoints
2 | (
3 | id char(27) NOT NULL,
4 | primary key (id),
5 | app_id char(27) NOT NULL,
6 | url TEXT NOT NULL,
7 | topics JSON NOT NULL,
8 | status char(127) NOT NULL
9 | );
--------------------------------------------------------------------------------
/server/migrations/20240523211340_create_events_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE events
2 | (
3 | id char(27) NOT NULL,
4 | primary key (id),
5 | app_id char(27) NOT NULL,
6 | payload JSON NOT NULL,
7 | topic text NOT NULL,
8 | created_at TIMESTAMP NOT NULL
9 | );
10 |
--------------------------------------------------------------------------------
/server/migrations/20240526112326_create_message_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE messages
2 | (
3 | id char(27) NOT NULL,
4 | primary key (id),
5 | event_id char(27) NOT NULL,
6 | endpoint_id char(27) NOT NULL
7 | );
8 |
9 | CREATE TABLE attempts
10 | (
11 | message_id char(27) NOT NULL,
12 | attempt SMALLINT NOT NULL,
13 | primary key(message_id, attempt),
14 | status_numeric SMALLINT NULL,
15 | status_unknown TEXT NULL
16 | );
17 |
--------------------------------------------------------------------------------
/server/migrations/20240531074624_create_attempt_log_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE attempt_logs
2 | (
3 | message_id char(27) NOT NULL,
4 | attempt SMALLINT NOT NULL,
5 | primary key(message_id, attempt),
6 | processing_time INT NOT NULL,
7 | response_time INT NOT NULL,
8 | response_body TEXT NULL
9 | );
10 |
--------------------------------------------------------------------------------
/server/server.http:
--------------------------------------------------------------------------------
1 | @url = http://localhost:8090
2 |
3 | ### Health check
4 | GET {{url}}/health_check
5 | Content-Type: application/json
6 |
7 | ### Create application
8 | POST {{url}}/application
9 | Content-Type: application/json
10 |
11 | {
12 | "name": "Dummy application"
13 | }
14 |
15 | > {%
16 | client.global.set("app_id", response.body.id);
17 | %}
18 |
19 | ### Create endpoint
20 | POST {{url}}/application/{{app_id}}/endpoint
21 | Content-Type: application/json
22 |
23 | {
24 | "url": "http://localhost:8080",
25 | "topics": [
26 | "contact.updated",
27 | "contact.created"
28 | ]
29 | }
30 |
31 | > {%
32 | client.global.set("endpoint_id", response.body.id);
33 | %}
34 |
35 | ### Create event
36 | POST {{url}}/application/{{app_id}}/event
37 | Content-Type: application/json
38 |
39 | {
40 | "topic": "contact.created",
41 | "payload": {
42 | "foo": "bar",
43 | "nested": {
44 | "test": [
45 | "123",
46 | "ABC"
47 | ]
48 | }
49 | }
50 | }
51 |
52 | ### Disable endpoint
53 | POST {{url}}/application/{{app_id}}/endpoint/{{endpoint_id}}/disable
54 | Content-Type: application/json
55 |
56 | ### Enable endpoint
57 | POST {{url}}/application/{{app_id}}/endpoint/{{endpoint_id}}/enable
58 | Content-Type: application/json
--------------------------------------------------------------------------------
/server/src/amqp.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeMap;
2 | use std::time::Duration;
3 |
4 | use lapin::options::{
5 | BasicPublishOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions,
6 | };
7 | use lapin::publisher_confirm::Confirmation;
8 | use lapin::types::{AMQPType, AMQPValue, FieldTable, ShortString};
9 | use lapin::{BasicProperties, Channel, Connection, ConnectionProperties, ExchangeKind};
10 | use log::info;
11 | use serde::de::DeserializeOwned;
12 | use serde::Serialize;
13 | use serde_json::Value;
14 |
15 | use crate::cmd::AsyncMessage;
16 | use crate::config::AMQPConfig;
17 |
18 | pub async fn establish_connection_with_rabbit(amqp_config: AMQPConfig) -> Channel {
19 | let addr = amqp_config.connection_string();
20 | let conn = Connection::connect(&addr, ConnectionProperties::default())
21 | .await
22 | .unwrap();
23 |
24 | info!("connected established with rabbitmq");
25 |
26 | let channel = conn.create_channel().await.unwrap();
27 |
28 | let args = FieldTable::from(BTreeMap::from(
29 | [(
30 | ShortString::from("x-delayed-type"),
31 | AMQPValue::try_from(&Value::String(String::from("direct")), AMQPType::LongString)
32 | .unwrap(),
33 | ); 1],
34 | ));
35 |
36 | channel
37 | .exchange_declare(
38 | &amqp_config.sent_message_exchange_name(),
39 | ExchangeKind::Custom(String::from("x-delayed-message")),
40 | ExchangeDeclareOptions::default(),
41 | args,
42 | )
43 | .await
44 | .unwrap();
45 |
46 | let queue = channel
47 | .queue_declare(
48 | &amqp_config.sent_message_queue_name(),
49 | QueueDeclareOptions::default(),
50 | FieldTable::default(),
51 | )
52 | .await
53 | .unwrap();
54 |
55 | channel
56 | .queue_bind(
57 | queue.name().as_str(),
58 | &amqp_config.sent_message_exchange_name(),
59 | "",
60 | QueueBindOptions::default(),
61 | FieldTable::default(),
62 | )
63 | .await
64 | .unwrap();
65 |
66 | info!("queue declared {:?}", queue);
67 |
68 | channel
69 | }
70 |
71 | pub struct Publisher {
72 | channel: Channel,
73 | amqp_config: AMQPConfig,
74 | }
75 |
76 | impl Publisher {
77 | pub fn new(channel: Channel, amqp_config: AMQPConfig) -> Self {
78 | Self {
79 | channel,
80 | amqp_config,
81 | }
82 | }
83 |
84 | pub async fn publish(&self, message: AsyncMessage) {
85 | self.do_publish(message, BasicProperties::default()).await
86 | }
87 |
88 | pub async fn publish_delayed(&self, message: AsyncMessage, delay: Duration) {
89 | let btree: BTreeMap<_, _> = [(
90 | ShortString::from("x-delay"),
91 | AMQPValue::LongLongInt(delay.as_millis() as i64),
92 | )]
93 | .into();
94 | let headers = FieldTable::from(btree);
95 | let properties = BasicProperties::default().with_headers(headers);
96 |
97 | self.do_publish(message, properties).await
98 | }
99 |
100 | fn resolve_exchange(&self, message: &AsyncMessage) -> String {
101 | match message {
102 | AsyncMessage::SentMessage(_) => self.amqp_config.sent_message_exchange_name().clone(),
103 | }
104 | }
105 |
106 | async fn do_publish(&self, message: AsyncMessage, properties: BasicProperties) {
107 | let confirm = self
108 | .channel
109 | .basic_publish(
110 | self.resolve_exchange(&message).as_str(),
111 | "",
112 | BasicPublishOptions::default(),
113 | &Serializer::serialize(message),
114 | properties,
115 | )
116 | .await
117 | .unwrap()
118 | .await
119 | .unwrap();
120 |
121 | assert_eq!(confirm, Confirmation::NotRequested);
122 | }
123 | }
124 |
125 | pub struct Serializer {}
126 |
127 | impl Serializer {
128 | pub fn deserialize(binary: &[u8]) -> T
129 | where
130 | T: DeserializeOwned,
131 | {
132 | let msg = String::from_utf8_lossy(binary);
133 |
134 | serde_json::from_str(&msg).unwrap()
135 | }
136 |
137 | pub fn serialize(value: T) -> Vec
138 | // is possible to return &[u8] ?
139 | where
140 | T: Serialize,
141 | {
142 | let string = serde_json::to_string(&value);
143 |
144 | string.unwrap().as_bytes().to_vec()
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/server/src/app.rs:
--------------------------------------------------------------------------------
1 | use std::net::TcpListener;
2 |
3 | use actix_web::dev::Server;
4 | use actix_web::middleware::Logger;
5 | use actix_web::web::Data;
6 | use actix_web::{App, HttpServer};
7 | use log::info;
8 | use sqlx::PgPool;
9 |
10 | use crate::amqp::{establish_connection_with_rabbit, Publisher};
11 | use crate::config::AMQPConfig;
12 | use crate::dispatch_consumer::consume;
13 | use crate::routes::routes;
14 | use crate::storage::Storage;
15 |
16 | pub async fn run_server(
17 | listener: TcpListener,
18 | pool: PgPool,
19 | amqp_config: AMQPConfig,
20 | ) -> Result {
21 | let channel = establish_connection_with_rabbit(amqp_config.clone()).await;
22 | let storage = Data::new(Storage::new(pool));
23 | let publisher = Data::new(Publisher::new(channel.clone(), amqp_config));
24 | let app = move || {
25 | App::new()
26 | .wrap(Logger::default())
27 | .app_data(storage.clone())
28 | .app_data(publisher.clone())
29 | .configure(routes)
30 | };
31 |
32 | let addr = listener.local_addr().unwrap();
33 | let server = HttpServer::new(app).listen(listener)?.run();
34 |
35 | info!("Webhooks server is listening for requests on {}", addr);
36 |
37 | Ok(server)
38 | }
39 |
40 | pub async fn run_dispatcher(pool: PgPool, amqp_config: AMQPConfig) {
41 | let channel = establish_connection_with_rabbit(amqp_config.clone()).await;
42 |
43 | consume(channel, "dispatcher", Storage::new(pool), amqp_config).await;
44 | }
45 |
--------------------------------------------------------------------------------
/server/src/bin/dispatcher.rs:
--------------------------------------------------------------------------------
1 | use dotenv::dotenv;
2 | use envconfig::Envconfig;
3 | use sqlx::PgPool;
4 |
5 | use server::app::run_dispatcher;
6 | use server::config::{AMQPConfig, PostgresConfig};
7 | use server::logs::init_log;
8 |
9 | #[tokio::main]
10 | async fn main() {
11 | dotenv().ok();
12 | init_log();
13 |
14 | let con_string = PostgresConfig::init_from_env().unwrap().connection_string();
15 | let pool = PgPool::connect(&con_string).await.unwrap();
16 |
17 | let amqp_config = AMQPConfig::init_from_env().unwrap();
18 |
19 | run_dispatcher(pool, amqp_config).await;
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/bin/server.rs:
--------------------------------------------------------------------------------
1 | use std::net::TcpListener;
2 |
3 | use dotenv::dotenv;
4 | use envconfig::Envconfig;
5 | use sqlx::PgPool;
6 |
7 | use server::app::run_server;
8 | use server::config::{AMQPConfig, PostgresConfig, ServerConfig};
9 | use server::logs::init_log;
10 |
11 | #[actix_web::main]
12 | async fn main() -> Result<(), std::io::Error> {
13 | dotenv().ok();
14 | init_log();
15 |
16 | let config = ServerConfig::init_from_env().unwrap();
17 | let listener = TcpListener::bind((config.host, config.port))
18 | .unwrap_or_else(|_| panic!("Failed to bind port {}", config.port));
19 |
20 | let con_string = PostgresConfig::init_from_env().unwrap().connection_string();
21 | let pool = PgPool::connect(&con_string).await.unwrap();
22 |
23 | let amqp_config = AMQPConfig::init_from_env().unwrap();
24 |
25 | run_server(listener, pool, amqp_config).await?.await
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/circuit_breaker.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashMap;
2 | use std::future::Future;
3 |
4 | use log::debug;
5 |
6 | #[derive(Copy, Clone, PartialEq)]
7 | pub enum State {
8 | Closed,
9 | Open,
10 | }
11 |
12 | #[derive(PartialEq, Debug)]
13 | pub enum Error {
14 | Rejected,
15 | Open(T),
16 | Closed(T),
17 | }
18 |
19 | // todo extract policy
20 | pub struct CircuitBreaker {
21 | max_fails: u32,
22 | storage: HashMap,
23 | // todo extract trait, allow to persist in redis,
24 | states: HashMap,
25 | }
26 |
27 | impl CircuitBreaker {
28 | pub fn new(max_fails: u32) -> Self {
29 | Self {
30 | max_fails,
31 | storage: HashMap::new(),
32 | states: HashMap::new(),
33 | }
34 | }
35 |
36 | // todo: key can be AsRef
37 | pub async fn call(&mut self, key: &String, function: F) -> Result>
38 | where
39 | F: FnOnce() -> Fut,
40 | Fut: Future