diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3be243f..b906221 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,3 +26,27 @@ jobs: - name: Run tests run: npm run test + rust: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + working-directory: rust + + steps: + - uses: actions/checkout@v6 + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - name: Check formatting + run: cargo fmt --all -- --check + - name: Lint + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Run tests + run: cargo test --workspace diff --git a/README.md b/README.md index 1590bb5..ecb7260 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ The action sdk to connect with aquila This action sdk is currently implemented in: - [Typescript](./ts/README.md) +- [Rust](./rust/README.md) # GRPC Communcation Flow diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 711c986..749f5ce 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -11,7 +11,7 @@ An action is a service **you** build and run. It connects to the Code0 runtime ( what functions it provides, what events it can fire, and what custom data types it understands. Users then pick your functions and events inside the flow editor. -Hercules is the TypeScript SDK for building actions. +Hercules is the SDK for building actions, available for TypeScript and Rust. ## What an Action does diff --git a/docs/tutorials/first-simple-action.mdx b/docs/tutorials/first-simple-action.mdx index a05bb30..367b1a8 100644 --- a/docs/tutorials/first-simple-action.mdx +++ b/docs/tutorials/first-simple-action.mdx @@ -3,6 +3,8 @@ title: First Simple Action description: Build a complete action with a function, event, and data type from scratch --- +import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; + This tutorial walks through building a complete action that: - Exposes a `fibonacci` function @@ -11,8 +13,11 @@ This tutorial walks through building a complete action that: ## 1. Create the RuntimeFunction -A `RuntimeFunction` contains the actual implementation. Decorate it with `@Identifier`, `@Signature`, and `@Name`, -then implement the `run()` method. +A `RuntimeFunction` contains the actual implementation. + + + +Decorate it with `@Identifier`, `@Signature`, and `@Name`, then implement the `run()` method. ```ts // src/functions/fibonacciRuntimeFunction.ts @@ -47,11 +52,62 @@ export class FibonacciRuntimeFunction { `@OmitRuntimeFunction()` prevents Hercules from auto-generating a public function definition from this class — we'll define that separately below. + + +Attach `#[hercules::runtime_function(...)]`, giving it an `identifier` and `signature`, then implement +`RuntimeFunctionHandler`. Attaching the macro registers it automatically — there's no separate registration call to +make later. + +```rust +// src/functions/fibonacci_runtime_function.rs +use hercules::{ + async_trait, Arguments, FunctionContext, PlainValue, Result, RuntimeFunctionHandler, +}; + +#[hercules::runtime_function( + identifier = "fibonacci_runtime", + signature = "(test: NUMBER): NUMBER", + name(en_US = "Fibonacci (Runtime)"), + display_message(en_US = "Computes the n-th Fibonacci number"), + omit_definition +)] +#[parameter(runtime_name = "test", name(en_US = "N"))] +pub struct FibonacciRuntimeFunction; + +#[async_trait] +impl RuntimeFunctionHandler for FibonacciRuntimeFunction { + async fn run(&self, context: &FunctionContext, args: &Arguments) -> Result { + let n: u64 = args.get("test")?; + + log::debug!( + "computing fibonacci({n}) for project={} execution={}", + context.project_id, + context.execution_id + ); + Ok(fib(n).into()) + } +} + +fn fib(n: u64) -> u64 { + if n <= 1 { + n + } else { + fib(n - 1) + fib(n - 2) + } +} +``` + +`omit_definition` prevents Hercules from auto-publishing this runtime function as its own public function — we'll +define that separately below. + + ## 2. Create the Function A `Function` extends the `RuntimeFunction` and adds public-facing metadata without changing the implementation. + + ```ts // src/functions/fibonacciFunction.ts import { Identifier, Name, Parameter } from '@code0-tech/hercules'; @@ -67,9 +123,31 @@ import { FibonacciRuntimeFunction } from './fibonacciRuntimeFunction.js'; }) export class FibonacciFunction extends FibonacciRuntimeFunction {} ``` + + +`#[hercules::function(base = ...)]` plays the same role as `extends` here: it points at the runtime function that +actually executes, and carries only metadata overrides — no `run()` of its own. + +```rust +// src/functions/fibonacci_function.rs +use super::fibonacci_runtime_function::FibonacciRuntimeFunction; + +#[hercules::function(base = FibonacciRuntimeFunction, identifier = "fibonacci")] +#[parameter( + runtime_name = "test", + name(en_US = "Input Number"), + description(en_US = "The position in the Fibonacci sequence"), + default_value = 10 +)] +pub struct FibonacciFunction; +``` + + ## 3. Create the RuntimeEvent + + ```ts // src/events/userCreatedRuntimeEvent.ts import { Identifier, Signature, Name, EventSetting } from '@code0-tech/hercules'; @@ -85,9 +163,30 @@ import { Identifier, Signature, Name, EventSetting } from '@code0-tech/hercules' }) export class UserCreatedRuntimeEvent {} ``` + + +```rust +// src/events/user_created_runtime_event.rs +#[hercules::runtime_event( + identifier = "user_created", + signature = "(userId: NUMBER): void", + name(en_US = "User Created") +)] +#[setting( + identifier = "FILTER_ROLE", + name(en_US = "Role Filter"), + description(en_US = "Only trigger for users with this role"), + optional +)] +pub struct UserCreatedRuntimeEvent; +``` + + ## 4. Create the Event + + ```ts // src/events/userCreatedEvent.ts import { Identifier, Name, Editable } from '@code0-tech/hercules'; @@ -98,9 +197,29 @@ import { UserCreatedRuntimeEvent } from './userCreatedRuntimeEvent.js'; @Editable(false) export class UserCreatedEvent extends UserCreatedRuntimeEvent {} ``` + + +```rust +// src/events/user_created_event.rs +use super::user_created_runtime_event::UserCreatedRuntimeEvent; + +#[hercules::event( + base = UserCreatedRuntimeEvent, + identifier = "user_created_event", + name(en_US = "On User Created") +)] +pub struct UserCreatedEvent; +``` + +`editable` defaults to `false`, so there's nothing to set explicitly here — add the bare `editable` flag to turn it +on. + + ## 5. Create the DataType + + Data types are backed by a Zod schema. Decorate the class with `@Identifier`, `@Name`, and `@Schema`. ```ts @@ -115,9 +234,28 @@ const EmailSchema = z.string().regex(/^[^@]+@[^@]+\.[^@]+$/); @Schema(EmailSchema) export class EmailDataType {} ``` + + +Data types are derived from a real Rust type's `schemars::JsonSchema` impl instead of a hand-written schema DSL — +`schemars`' own validation attributes (`#[schemars(regex(...))]`) double as the wire validation rules. + +```rust +// src/data_types/email_data_type.rs +use hercules::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[hercules::data_type(identifier = "email_address", name(en_US = "Email Address"))] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct EmailAddress(#[schemars(regex(pattern = r"^[^@]+@[^@]+\.[^@]+$"))] pub String); +``` + + ## 6. Wire It All Together + + ```ts // src/index.ts import { Action, CodeZeroEvent } from '@code0-tech/hercules'; @@ -164,14 +302,86 @@ action.connect(process.env.AUTH_TOKEN ?? 'token').catch((err: unknown) => { process.exit(1); }); ``` + + +Every type above already registered itself when its module got linked into the binary, so there's nothing left to +wire up beyond building the `Action` and connecting it: + +```rust +// src/main.rs +mod data_types; +mod events; +mod functions; + +use hercules::{Action, ConfigurationDefinition, HerculesEvent, Translation}; +use tokio_stream::StreamExt; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn build_action() -> Action { + Action::new(env("ACTION_ID", "example-action"), env("VERSION", "0.0.0")) + .aquila_url(env("AQUILA_URL", "127.0.0.1:8081")) + .author("my-org") + .icon("tabler:bolt") + .documentation("A simple example action") + .name([Translation::new("en-US", "Example Action")]) + .configuration( + ConfigurationDefinition::new("EXAMPLE_CONFIG", "string") + .name([Translation::new("en-US", "Example Config")]), + ) +} + +#[tokio::main] +async fn main() -> hercules::Result<()> { + let action = build_action(); + let mut events = action.subscribe(); + + // The returned `Connected` handle can be discarded immediately: the + // background dispatch task (started inside `connect`) holds its own + // `Arc` to the shared connection state. + action + .connect(env("AUTH_TOKEN", "token"), None) + .await + .unwrap_or_else(|err| panic!("failed to connect to Aquila: {err}")); + + // React to connection lifecycle events on the main task for as long as + // the process runs. Panicking here (unlike inside a spawned task) takes + // the whole process down on an unrecoverable stream error. + while let Some(event) = events.next().await { + match event { + HerculesEvent::Connected => log::info!("connected to Aquila"), + HerculesEvent::Error(error) => panic!("Aquila stream error: {error}"), + _ => {} + } + } + + Ok(()) +} +``` + + ## Firing an Event To fire the `user_created` event from within your action (e.g. after a webhook call): + + ```ts await action.fire(UserCreatedRuntimeEvent, projectId, { userId: 42 }); ``` The second argument is the project ID this event belongs to, and the third is the payload matching the event's signature. + + +```rust +action.fire("user_created", project_id, serde_json::json!({ "userId": 42 }))?; +``` + +The second argument is the project ID this event belongs to, and the third is the payload matching the event's +signature. + + diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..bddb385 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1362 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hercules" +version = "0.0.0" +dependencies = [ + "async-trait", + "hercules-macros", + "inventory", + "log", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tucana", +] + +[[package]] +name = "hercules-macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libc" +version = "0.2.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pbjson" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c" +dependencies = [ + "base64", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200" +dependencies = [ + "heck", + "itertools", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14e2757d877c0f607a82ce1b8560e224370f159d66c5d52eb55ea187ef0350e" +dependencies = [ + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simple-example-rs" +version = "0.0.0" +dependencies = [ + "env_logger", + "hercules", + "log", + "schemars", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tucana", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tucana" +version = "0.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473db4c6dff2f83d8275b92abe90873a5e68b5ca5cf02e2aa0154fdd69bc925a" +dependencies = [ + "pbjson", + "pbjson-build", + "pbjson-types", + "prost", + "prost-build", + "prost-types", + "serde", + "serde_json", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..359566e --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "2" +members = [ + "hercules", + "hercules-macros", + "examples/simple-example-rs", +] + +[workspace.package] +version = "0.0.0" +edition = "2021" +license = "ISC" +repository = "https://github.com/code0-tech/hercules" + +[workspace.dependencies] +# Published from https://github.com/code0-tech/tucana (build/rust) — the same proto +# definitions the TS SDK consumes as `@code0-tech/tucana` from npm. +tucana = { version = "0.0.76", default-features = false, features = ["aquila"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +tonic = "0.14" +async-trait = "0.1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +# preserve_order: DataType field order in the generated wire `type` string +# must match the struct's declaration order, the same way Zod's object types +# preserve declaration order — see hercules::data_type. +serde_json = { version = "1", features = ["preserve_order"] } +schemars = "1" +log = "0.4" +inventory = "0.3" +tokio-stream = { version = "0.1", features = ["sync"] } diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..336a76b --- /dev/null +++ b/rust/README.md @@ -0,0 +1,119 @@ +# hercules + +Rust SDK for building Hercules actions that connect to Aquila. An action +registers the functions it exposes, the events it can fire, and any custom +data types it needs, then talks to Aquila over a persistent gRPC stream. + +Depends on the [`tucana`](https://crates.io/crates/tucana) crate for the +underlying protobuf types. + +**Not yet included:** the `definitions/` standard library (the ~150 +`std::*` function/data-type declarations) and an equivalent of the `hercules +export` CLI. Both are independent of the core framework and can be added +later. + +## Installation + +Not yet published to crates.io — until then, link it locally by path: + +```toml +[dependencies] +hercules = { path = "../hercules" } +``` + +## Quick start + +Attach `#[hercules::runtime_function]` to a struct and implement +`RuntimeFunctionHandler` — that's enough to register it, no separate call +required: + +```rust +use hercules::{Arguments, FunctionContext, PlainValue, Result, RuntimeFunctionHandler, async_trait}; + +#[hercules::runtime_function(identifier = "add", signature = "(a: NUMBER, b: NUMBER): NUMBER")] +struct Add; + +#[async_trait] +impl RuntimeFunctionHandler for Add { + async fn run(&self, _ctx: &FunctionContext, args: &Arguments) -> Result { + let a: f64 = args.get("a")?; + let b: f64 = args.get("b")?; + Ok((a + b).into()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let action = hercules::Action::new("my-action", "0.1.0").aquila_url("127.0.0.1:8081"); + + let action = action.connect("token", None).await?; + std::future::pending::<()>().await; // keep dispatching execution requests + # let _ = action; + Ok(()) +} +``` + +Only `identifier` and `version` are required on `Action::new` — `author`, +`icon`, `documentation`, `name`, and `configuration` are optional chained +setters. + +See [`examples/simple-example-rs`](./examples/simple-example-rs) for a +runnable action exercising every registration kind: a runtime function, its +public variant, a data type, and a runtime event. + +```bash +cd examples/simple-example-rs +cp .env.example .env && set -a && source .env && set +a +cargo run +``` + +## Public vs. runtime functions + +A `RuntimeFunction` holds the actual implementation. A `Function` is an +optional, separately-identified public variant of it — same execution +behavior, its own metadata (identifier, parameter defaults, ...). Use +`#[hercules::function(base = ...)]` when you want to expose a runtime +function under different public-facing metadata without duplicating logic: + +```rust +#[hercules::function(base = Add, identifier = "sum")] +#[parameter(runtime_name = "a", default_value = 0)] +struct Sum; +``` + +Events follow the same pattern: `#[hercules::runtime_event]` for the +internal definition, `#[hercules::event(base = ...)]` for a public, +user-facing variant. + +## Data types + +Data types are derived from a Rust type's `schemars::JsonSchema` impl +instead of a hand-written schema DSL — `schemars`' own validation attributes +double as the wire validation rules: + +```rust +use hercules::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[hercules::data_type(identifier = "email_address", name(en_US = "Email Address"))] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct EmailAddress(#[schemars(regex(pattern = r"^[^@]+@[^@]+\.[^@]+$"))] pub String); +``` + +## Firing events + +```rust +action.fire("user_created", project_id, serde_json::json!({ "userId": 42 }))?; +``` + +The second argument is the project ID the event belongs to; the third is +the payload matching the event's signature. + +## Testing without a live Aquila + +`Action::build_module()` returns the wire `Module` without connecting, so +registration logic (macro-generated metadata, `Function`/`Event` merging, +data type schema resolution) can be unit tested directly — see +[`examples/simple-example-rs/src/main.rs`](./examples/simple-example-rs/src/main.rs)'s +`tests` module for a working example. diff --git a/rust/examples/simple-example-rs/.env.example b/rust/examples/simple-example-rs/.env.example new file mode 100644 index 0000000..745e25f --- /dev/null +++ b/rust/examples/simple-example-rs/.env.example @@ -0,0 +1,4 @@ +ACTION_ID=example-action +VERSION=0.0.0 +AQUILA_URL=127.0.0.1:8081 +AUTH_TOKEN=token diff --git a/rust/examples/simple-example-rs/Cargo.toml b/rust/examples/simple-example-rs/Cargo.toml new file mode 100644 index 0000000..72cdddc --- /dev/null +++ b/rust/examples/simple-example-rs/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "simple-example-rs" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +hercules = { path = "../../hercules" } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio-stream.workspace = true +serde = { workspace = true } +serde_json.workspace = true +schemars.workspace = true +log.workspace = true +env_logger = "0.11" + +[dev-dependencies] +tucana.workspace = true diff --git a/rust/examples/simple-example-rs/src/data_types/email_data_type.rs b/rust/examples/simple-example-rs/src/data_types/email_data_type.rs new file mode 100644 index 0000000..27e6945 --- /dev/null +++ b/rust/examples/simple-example-rs/src/data_types/email_data_type.rs @@ -0,0 +1,11 @@ +use hercules::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A data type derived from a real Rust type instead of a hand-written +/// schema DSL: `#[derive(JsonSchema)]` gives us the structural type +/// (`"string"`), and `#[schemars(regex(...))]` gives us the validation rule +/// Aquila enforces on top of it. +#[hercules::data_type(identifier = "email_address", name(en_US = "Email Address"))] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct EmailAddress(#[schemars(regex(pattern = r"^[^@]+@[^@]+\.[^@]+$"))] pub String); diff --git a/rust/examples/simple-example-rs/src/data_types/mod.rs b/rust/examples/simple-example-rs/src/data_types/mod.rs new file mode 100644 index 0000000..c5671ad --- /dev/null +++ b/rust/examples/simple-example-rs/src/data_types/mod.rs @@ -0,0 +1 @@ +pub mod email_data_type; diff --git a/rust/examples/simple-example-rs/src/events/mod.rs b/rust/examples/simple-example-rs/src/events/mod.rs new file mode 100644 index 0000000..1335f7d --- /dev/null +++ b/rust/examples/simple-example-rs/src/events/mod.rs @@ -0,0 +1 @@ +pub mod user_created_runtime_event; diff --git a/rust/examples/simple-example-rs/src/events/user_created_runtime_event.rs b/rust/examples/simple-example-rs/src/events/user_created_runtime_event.rs new file mode 100644 index 0000000..a1ec2ed --- /dev/null +++ b/rust/examples/simple-example-rs/src/events/user_created_runtime_event.rs @@ -0,0 +1,10 @@ +#[hercules::runtime_event( + identifier = "user_created", + signature = "(): { userId: NUMBER }", + name(en_US = "User created event"), + display_message(en_US = "Triggers on user creation"), + description( + en_US = "Triggers on user creation and has a payload including the user database id" + ) +)] +pub struct UserCreatedRuntimeEvent; diff --git a/rust/examples/simple-example-rs/src/functions/fibonacci_function.rs b/rust/examples/simple-example-rs/src/functions/fibonacci_function.rs new file mode 100644 index 0000000..8fb9202 --- /dev/null +++ b/rust/examples/simple-example-rs/src/functions/fibonacci_function.rs @@ -0,0 +1,14 @@ +use super::fibonacci_runtime_function::FibonacciRuntimeFunction; + +/// The public, user-facing variant of [`FibonacciRuntimeFunction`]: same +/// execution behavior, its own identifier and a default input value. +/// Execution always dispatches through the runtime function — this struct +/// carries no `run()` of its own, only metadata overrides. +#[hercules::function(base = FibonacciRuntimeFunction, identifier = "fibonacci")] +#[parameter( + runtime_name = "test", + name(en_US = "Input Number"), + description(en_US = "The position in the Fibonacci sequence"), + default_value = 10 +)] +pub struct FibonacciFunction; diff --git a/rust/examples/simple-example-rs/src/functions/fibonacci_runtime_function.rs b/rust/examples/simple-example-rs/src/functions/fibonacci_runtime_function.rs new file mode 100644 index 0000000..95b997b --- /dev/null +++ b/rust/examples/simple-example-rs/src/functions/fibonacci_runtime_function.rs @@ -0,0 +1,37 @@ +use hercules::{ + async_trait, Arguments, FunctionContext, PlainValue, Result, RuntimeFunctionHandler, +}; + +#[hercules::runtime_function( + identifier = "fibonacci_runtime", + signature = "(test: NUMBER): NUMBER", + name(en_US = "Fibonacci (Runtime)"), + display_message(en_US = "Computes the n-th Fibonacci number"), + omit_definition +)] +#[parameter(runtime_name = "test", name(en_US = "N"))] +pub struct FibonacciRuntimeFunction; + +#[async_trait] +impl RuntimeFunctionHandler for FibonacciRuntimeFunction { + async fn run(&self, context: &FunctionContext, args: &Arguments) -> Result { + // Missing or malformed, `args.get` reports it back to Aquila as a + // runtime error on its own — nothing to check by hand here. + let n: u64 = args.get("test")?; + + log::debug!( + "computing fibonacci({n}) for project={} execution={}", + context.project_id, + context.execution_id + ); + Ok(fib(n).into()) + } +} + +fn fib(n: u64) -> u64 { + if n <= 1 { + n + } else { + fib(n - 1) + fib(n - 2) + } +} diff --git a/rust/examples/simple-example-rs/src/functions/mod.rs b/rust/examples/simple-example-rs/src/functions/mod.rs new file mode 100644 index 0000000..e1f67dd --- /dev/null +++ b/rust/examples/simple-example-rs/src/functions/mod.rs @@ -0,0 +1,2 @@ +pub mod fibonacci_function; +pub mod fibonacci_runtime_function; diff --git a/rust/examples/simple-example-rs/src/main.rs b/rust/examples/simple-example-rs/src/main.rs new file mode 100644 index 0000000..603614c --- /dev/null +++ b/rust/examples/simple-example-rs/src/main.rs @@ -0,0 +1,119 @@ +mod data_types; +mod events; +mod functions; + +use hercules::{Action, ConfigurationDefinition, HerculesEvent, Translation}; +use tokio_stream::StreamExt; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +/// `FibonacciRuntimeFunction`, `FibonacciFunction`, `EmailAddress` and +/// `UserCreatedRuntimeEvent` never appear below — attaching +/// `#[hercules::runtime_function]` / `function` / `data_type` / +/// `runtime_event` registered each of them automatically as part of +/// `Action::new` (see `hercules::registration`). There's simply nothing left +/// to wire up by hand for this example. +fn build_action() -> Action { + Action::new(env("ACTION_ID", "example-action"), env("VERSION", "0.0.0")) + .aquila_url(env("AQUILA_URL", "127.0.0.1:8081")) + .author("code0-tech") + .icon("tabler:bolt") + .documentation("A simple example action") + .name([Translation::new("en-US", "Example Action")]) + .configuration( + ConfigurationDefinition::new("EXAMPLE_CONFIG", "string") + .name([Translation::new("en-US", "Example Config")]), + ) +} + +#[tokio::main] +async fn main() -> hercules::Result<()> { + // RUST_LOG=hercules=debug,simple_example_rs=debug cargo run + env_logger::init(); + + let action = build_action(); + let mut events = action.subscribe(); + + // The returned `Connected` handle can be discarded immediately: the + // background dispatch task (started inside `connect`) holds its own + // `Arc` to the shared connection state, so nothing here needs to keep + // it alive. + action + .connect(env("AUTH_TOKEN", "token"), None) + .await + .unwrap_or_else(|err| panic!("failed to connect to Aquila: {err}")); + + // React to connection lifecycle events on the main task for as long as + // the process runs. Panicking here (unlike inside a spawned task) takes + // the whole process down on an unrecoverable stream error. + while let Some(event) = events.next().await { + match event { + HerculesEvent::Connected => log::info!("connected to Aquila"), + HerculesEvent::Error(error) => panic!("Aquila stream error: {error}"), + _ => {} + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use hercules::wire::Module; + + use super::build_action; + + /// Exercises the real registration path end to end — link-time + /// `inventory` auto-registration, `Function`/`RuntimeFunction` merging, + /// and `schemars`-driven data type resolution — by building the wire + /// `Module` without connecting. + #[test] + fn builds_a_well_formed_module() { + let module: Module = build_action().build_module(); + + assert_eq!(module.identifier, "example-action"); + + let email = module + .definition_data_types + .iter() + .find(|dt| dt.identifier == "email_address") + .expect("email_address data type"); + assert_eq!(email.r#type, "string"); + assert!(matches!( + email.rules.first().and_then(|r| r.config.clone()), + Some(tucana::shared::definition_data_type_rule::Config::Regex(_)) + )); + + let fibonacci = module + .function_definitions + .iter() + .find(|f| f.runtime_name == "fibonacci") + .expect("fibonacci function"); + assert_eq!(fibonacci.runtime_definition_name, "fibonacci_runtime"); + let param = &fibonacci.parameter_definitions[0]; + assert_eq!(param.runtime_name, "test"); + assert_eq!( + param.default_value.as_ref().and_then(|v| v.kind.clone()), + Some(tucana::shared::value::Kind::NumberValue( + tucana::shared::NumberValue { + number: Some(tucana::shared::number_value::Number::Integer(10)) + } + )) + ); + + // omit_definition on the runtime function means it isn't separately + // published under its own identifier. + assert!(module + .function_definitions + .iter() + .all(|f| f.runtime_name != "fibonacci_runtime")); + assert_eq!( + module.runtime_function_definitions[0].runtime_name, + "fibonacci_runtime" + ); + + assert_eq!(module.runtime_flow_types[0].identifier, "user_created"); + } +} diff --git a/rust/hercules-macros/Cargo.toml b/rust/hercules-macros/Cargo.toml new file mode 100644 index 0000000..d0b9c6e --- /dev/null +++ b/rust/hercules-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hercules-macros" +description = "Attribute macros for the hercules action SDK" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full", "extra-traits"] } +quote = "1" +proc-macro2 = "1" diff --git a/rust/hercules-macros/src/data_type.rs b/rust/hercules-macros/src/data_type.rs new file mode 100644 index 0000000..e7d654e --- /dev/null +++ b/rust/hercules-macros/src/data_type.rs @@ -0,0 +1,41 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::ItemStruct; + +use crate::parse::{hercules_path, translation_vec, AttrArgs}; + +pub fn expand(attr: TokenStream, item: TokenStream) -> syn::Result { + let item_struct: ItemStruct = syn::parse2(item)?; + let args = AttrArgs::parse(attr)?; + let hercules = hercules_path(); + + let identifier = args.required_string("identifier")?; + let name = translation_vec(&args.translations("name")?, &hercules); + let display_message = translation_vec(&args.translations("display_message")?, &hercules); + let alias = translation_vec(&args.translations("alias")?, &hercules); + let generic_keys: Vec = args.string_list("generic_keys")?; + + let ident = &item_struct.ident; + Ok(quote! { + #item_struct + + impl #hercules::DataType for #ident { + fn meta() -> #hercules::DataTypeMeta { + #hercules::DataTypeMeta { + identifier: #identifier.to_string(), + name: #name, + display_message: #display_message, + alias: #alias, + generic_keys: vec![#(#generic_keys.to_string()),*], + } + } + } + + #hercules::inventory::submit! { + #hercules::Registration(|action| { + action.register_data_type::<#ident>() + .unwrap_or_else(|e| panic!("failed to register data type {}: {e}", stringify!(#ident))); + }) + } + }) +} diff --git a/rust/hercules-macros/src/event.rs b/rust/hercules-macros/src/event.rs new file mode 100644 index 0000000..5907ea4 --- /dev/null +++ b/rust/hercules-macros/src/event.rs @@ -0,0 +1,71 @@ +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::ItemStruct; + +use crate::items::setting_tokens; +use crate::parse::{ + hercules_path, optional_string, optional_translation_vec, take_repeated, AttrArgs, +}; + +pub fn expand(attr: TokenStream, item: TokenStream) -> syn::Result { + let mut item_struct: ItemStruct = syn::parse2(item)?; + let args = AttrArgs::parse(attr)?; + let hercules = hercules_path(); + + let base = args.path("base")?.ok_or_else(|| { + syn::Error::new( + Span::call_site(), + "missing required `base = SomeRuntimeEvent`", + ) + })?; + + let settings = take_repeated(&mut item_struct, "setting")? + .iter() + .map(|s| setting_tokens(s, &hercules)) + .collect::>>()?; + + let identifier = optional_string(args.string("identifier"))?; + let signature = optional_string(args.string("signature"))?; + let name = optional_translation_vec(&args, "name", &hercules)?; + let description = optional_translation_vec(&args, "description", &hercules)?; + let documentation = optional_translation_vec(&args, "documentation", &hercules)?; + let display_message = optional_translation_vec(&args, "display_message", &hercules)?; + let alias = optional_translation_vec(&args, "alias", &hercules)?; + let display_icon = optional_string(args.string("display_icon"))?; + let editable = if args.flag("editable") { + quote!(Some(true)) + } else { + quote!(None) + }; + + let ident = &item_struct.ident; + Ok(quote! { + #item_struct + + impl #hercules::Event for #ident { + type Base = #base; + + fn meta() -> #hercules::EventMeta { + #hercules::EventMeta { + identifier: #identifier, + signature: #signature, + settings: vec![#(#settings),*], + editable: #editable, + name: #name, + description: #description, + documentation: #documentation, + display_message: #display_message, + alias: #alias, + display_icon: #display_icon, + } + } + } + + #hercules::inventory::submit! { + #hercules::Registration(|action| { + action.register_event::<#ident>() + .unwrap_or_else(|e| panic!("failed to register event {}: {e}", stringify!(#ident))); + }) + } + }) +} diff --git a/rust/hercules-macros/src/function.rs b/rust/hercules-macros/src/function.rs new file mode 100644 index 0000000..29cb9de --- /dev/null +++ b/rust/hercules-macros/src/function.rs @@ -0,0 +1,80 @@ +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::ItemStruct; + +use crate::items::parameter_tokens; +use crate::parse::{ + hercules_path, optional_string, optional_translation_vec, take_repeated, AttrArgs, +}; + +pub fn expand(attr: TokenStream, item: TokenStream) -> syn::Result { + let mut item_struct: ItemStruct = syn::parse2(item)?; + let args = AttrArgs::parse(attr)?; + let hercules = hercules_path(); + + let base = args.path("base")?.ok_or_else(|| { + syn::Error::new( + Span::call_site(), + "missing required `base = SomeRuntimeFunction`", + ) + })?; + + let parameters = take_repeated(&mut item_struct, "parameter")? + .iter() + .map(|p| parameter_tokens(p, &hercules)) + .collect::>>()?; + + let identifier = optional_string(args.string("identifier"))?; + let signature = optional_string(args.string("signature"))?; + let name = optional_translation_vec(&args, "name", &hercules)?; + let description = optional_translation_vec(&args, "description", &hercules)?; + let documentation = optional_translation_vec(&args, "documentation", &hercules)?; + let deprecation_message = optional_translation_vec(&args, "deprecation_message", &hercules)?; + let display_message = optional_translation_vec(&args, "display_message", &hercules)?; + let alias = optional_translation_vec(&args, "alias", &hercules)?; + let display_icon = optional_string(args.string("display_icon"))?; + let design = optional_string(args.string("design"))?; + // Presence-only: this overrides `throws_error` to `true`; to leave it + // unset (inherit the base), simply omit the flag. + let throws_error = if args.flag("throws_error") { + quote!(Some(true)) + } else { + quote!(None) + }; + + let ident = &item_struct.ident; + Ok(quote! { + #item_struct + + impl #hercules::Function for #ident { + type Base = #base; + + fn meta() -> #hercules::FunctionMeta { + #hercules::FunctionMeta { + identifier: #identifier, + signature: #signature, + name: #name, + description: #description, + documentation: #documentation, + deprecation_message: #deprecation_message, + display_message: #display_message, + alias: #alias, + display_icon: #display_icon, + design: #design, + throws_error: #throws_error, + parameters: vec![#(#parameters),*], + } + } + } + + // `Function` carries no instance/state (dispatch always resolves + // through `Base`), so unlike `runtime_function` this needs no + // `manual` escape hatch — it's always safe to auto-register. + #hercules::inventory::submit! { + #hercules::Registration(|action| { + action.register_function::<#ident>() + .unwrap_or_else(|e| panic!("failed to register function {}: {e}", stringify!(#ident))); + }) + } + }) +} diff --git a/rust/hercules-macros/src/items.rs b/rust/hercules-macros/src/items.rs new file mode 100644 index 0000000..dd27bde --- /dev/null +++ b/rust/hercules-macros/src/items.rs @@ -0,0 +1,67 @@ +//! Token builders for the two repeatable child items: +//! `#[parameter(...)]` (functions) and `#[setting(...)]` (events). + +use proc_macro2::TokenStream; +use quote::quote; + +use crate::parse::{translation_vec, AttrArgs}; + +pub fn parameter_tokens(args: &AttrArgs, hercules: &TokenStream) -> syn::Result { + let runtime_name = args.required_string("runtime_name")?; + let runtime_definition_name = match args.string("runtime_definition_name")? { + Some(s) => quote!(Some(#s.to_string())), + None => quote!(None), + }; + let name = translation_vec(&args.translations("name")?, hercules); + let description = translation_vec(&args.translations("description")?, hercules); + let documentation = translation_vec(&args.translations("documentation")?, hercules); + let default_value = match args.value_tokens("default_value")? { + Some(v) => quote!(Some(#hercules::serde_json::json!(#v))), + None => quote!(None), + }; + let hidden = args.flag("hidden"); + let optional = args.flag("optional"); + + Ok(quote! { + #hercules::ParameterMeta { + runtime_name: #runtime_name.to_string(), + runtime_definition_name: #runtime_definition_name, + name: #name, + description: #description, + documentation: #documentation, + default_value: #default_value, + hidden: #hidden, + optional: #optional, + } + }) +} + +pub fn setting_tokens(args: &AttrArgs, hercules: &TokenStream) -> syn::Result { + let identifier = args.required_string("identifier")?; + let unique = match args.string("unique")?.as_deref() { + Some("project") => quote!(#hercules::UniquenessScope::Project), + _ => quote!(#hercules::UniquenessScope::None), + }; + let linked: Vec = args.string_list("linked_data_type_identifiers")?; + let name = translation_vec(&args.translations("name")?, hercules); + let description = translation_vec(&args.translations("description")?, hercules); + let default_value = match args.value_tokens("default_value")? { + Some(v) => quote!(Some(#hercules::serde_json::json!(#v))), + None => quote!(None), + }; + let hidden = args.flag("hidden"); + let optional = args.flag("optional"); + + Ok(quote! { + #hercules::EventSettingMeta { + identifier: #identifier.to_string(), + unique: #unique, + linked_data_type_identifiers: vec![#(#linked.to_string()),*], + default_value: #default_value, + name: #name, + description: #description, + hidden: #hidden, + optional: #optional, + } + }) +} diff --git a/rust/hercules-macros/src/lib.rs b/rust/hercules-macros/src/lib.rs new file mode 100644 index 0000000..a9baf67 --- /dev/null +++ b/rust/hercules-macros/src/lib.rs @@ -0,0 +1,62 @@ +//! Attribute macros that turn `identifier = "...", name(en_US = "...")` +//! arguments — plus any repeated `#[parameter(...)]`/`#[setting(...)]` helper +//! attributes on the annotated struct — into a `meta()` associated function +//! implementing the corresponding `hercules` trait. Each macro parses its +//! arguments and generates that function once, at compile time; there's no +//! runtime reflection or metadata registry involved. + +mod data_type; +mod event; +mod function; +mod items; +mod parse; +mod runtime_event; +mod runtime_function; + +use proc_macro::TokenStream; + +fn run( + f: impl FnOnce( + proc_macro2::TokenStream, + proc_macro2::TokenStream, + ) -> syn::Result, + attr: TokenStream, + item: TokenStream, +) -> TokenStream { + f(attr.into(), item.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Declares a function Aquila can invoke. See the crate-level docs on +/// `hercules::RuntimeFunction` for the full attribute grammar. +#[proc_macro_attribute] +pub fn runtime_function(attr: TokenStream, item: TokenStream) -> TokenStream { + run(runtime_function::expand, attr, item) +} + +/// Declares a public [`hercules::Function`] that overrides metadata on top +/// of a `base = SomeRuntimeFunction`. +#[proc_macro_attribute] +pub fn function(attr: TokenStream, item: TokenStream) -> TokenStream { + run(function::expand, attr, item) +} + +/// Declares a data type derived from the struct's `schemars::JsonSchema` impl. +#[proc_macro_attribute] +pub fn data_type(attr: TokenStream, item: TokenStream) -> TokenStream { + run(data_type::expand, attr, item) +} + +/// Declares an internal event/flow-type an action can fire. +#[proc_macro_attribute] +pub fn runtime_event(attr: TokenStream, item: TokenStream) -> TokenStream { + run(runtime_event::expand, attr, item) +} + +/// Declares a public [`hercules::Event`] that overrides metadata on top of a +/// `base = SomeRuntimeEvent`. +#[proc_macro_attribute] +pub fn event(attr: TokenStream, item: TokenStream) -> TokenStream { + run(event::expand, attr, item) +} diff --git a/rust/hercules-macros/src/parse.rs b/rust/hercules-macros/src/parse.rs new file mode 100644 index 0000000..d299322 --- /dev/null +++ b/rust/hercules-macros/src/parse.rs @@ -0,0 +1,214 @@ +//! Shared parsing for the `#[hercules::...]` attribute macros' argument +//! lists (`identifier = "...", name(en_US = "...")`) and for the repeatable +//! helper attributes (`#[parameter(...)]`, `#[setting(...)]`) they read off +//! a struct and strip before re-emitting it. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse::Parser; +use syn::punctuated::Punctuated; +use syn::{Attribute, Expr, ExprLit, ItemStruct, Lit, Meta, MetaNameValue, Token}; + +pub struct AttrArgs { + metas: Vec, +} + +fn expr_str(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Ok(s.value()), + other => Err(syn::Error::new_spanned(other, "expected a string literal")), + } +} + +impl AttrArgs { + pub fn parse(tokens: TokenStream) -> syn::Result { + let metas = Punctuated::::parse_terminated.parse2(tokens)?; + Ok(Self { + metas: metas.into_iter().collect(), + }) + } + + fn find(&self, key: &str) -> Option<&Meta> { + self.metas.iter().find(|m| m.path().is_ident(key)) + } + + /// `key = "..."`. + pub fn string(&self, key: &str) -> syn::Result> { + match self.find(key) { + None => Ok(None), + Some(Meta::NameValue(MetaNameValue { value, .. })) => Ok(Some(expr_str(value)?)), + Some(other) => Err(syn::Error::new_spanned( + other, + format!("expected `{key} = \"...\"`"), + )), + } + } + + /// A required `key = "..."`. + pub fn required_string(&self, key: &str) -> syn::Result { + self.string(key)?.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("missing required `{key} = \"...\"`"), + ) + }) + } + + /// `key` present as a bare flag, e.g. `omit_definition`. + pub fn flag(&self, key: &str) -> bool { + matches!(self.find(key), Some(Meta::Path(_))) + } + + /// `key = SomeIdent` — used for `base = MyRuntimeFunction`. + pub fn path(&self, key: &str) -> syn::Result> { + match self.find(key) { + None => Ok(None), + Some(Meta::NameValue(MetaNameValue { + value: Expr::Path(p), + .. + })) => Ok(Some(p.path.clone())), + Some(other) => Err(syn::Error::new_spanned( + other, + format!("expected `{key} = SomeType`"), + )), + } + } + + /// `key(en_US = "...", de_DE = "...")`, returning `(locale, content)` + /// pairs. Underscores in the locale identifier become hyphens + /// (`en_US` -> `en-US`) since Rust identifiers can't contain them. + pub fn translations(&self, key: &str) -> syn::Result> { + let Some(Meta::List(list)) = self.find(key) else { + return Ok(vec![]); + }; + let entries = + list.parse_args_with(Punctuated::::parse_terminated)?; + entries + .into_iter() + .map(|nv| { + let code = nv + .path + .get_ident() + .ok_or_else(|| { + syn::Error::new_spanned( + &nv.path, + "expected a locale identifier, e.g. en_US", + ) + })? + .to_string() + .replace('_', "-"); + Ok((code, expr_str(&nv.value)?)) + }) + .collect() + } + + /// `key("a", "b")`. + pub fn string_list(&self, key: &str) -> syn::Result> { + let Some(Meta::List(list)) = self.find(key) else { + return Ok(vec![]); + }; + let entries = + list.parse_args_with(Punctuated::::parse_terminated)?; + Ok(entries.into_iter().map(|s| s.value()).collect()) + } + + /// A literal value for `default_value = `, kept as raw tokens so + /// the caller can splice it into `serde_json::json!(...)`. + pub fn value_tokens(&self, key: &str) -> syn::Result> { + match self.find(key) { + None => Ok(None), + Some(Meta::NameValue(MetaNameValue { value, .. })) => Ok(Some(quote!(#value))), + Some(other) => Err(syn::Error::new_spanned( + other, + format!("expected `{key} = `"), + )), + } + } +} + +/// Extracts and removes every `#[name(...)]` attribute on `item`, in source +/// order, parsing each occurrence's arguments as [`AttrArgs`]. This is how +/// `#[parameter(...)]`/`#[setting(...)]` can appear multiple times on one +/// struct: rustc never resolves them as real attributes because the outer +/// `#[hercules::...]` macro (which runs first) strips them before +/// re-emitting the struct. +pub fn take_repeated(item: &mut ItemStruct, name: &str) -> syn::Result> { + let mut found = Vec::new(); + let mut error = None; + item.attrs.retain(|attr: &Attribute| { + if !attr.path().is_ident(name) { + return true; + } + match attr.parse_args_with(Punctuated::::parse_terminated) { + Ok(metas) => found.push(AttrArgs { + metas: metas.into_iter().collect(), + }), + Err(e) => { + error.get_or_insert(e); + } + } + false + }); + match error { + Some(e) => Err(e), + None => Ok(found), + } +} + +/// `key1: T1, key2: T2, ...` boilerplate for the handful of translation +/// fields every meta struct shares. +pub fn translation_fields( + args: &AttrArgs, + hercules: &TokenStream, +) -> syn::Result { + Ok(TranslationFields { + name: translation_vec(&args.translations("name")?, hercules), + description: translation_vec(&args.translations("description")?, hercules), + documentation: translation_vec(&args.translations("documentation")?, hercules), + deprecation_message: translation_vec(&args.translations("deprecation_message")?, hercules), + display_message: translation_vec(&args.translations("display_message")?, hercules), + alias: translation_vec(&args.translations("alias")?, hercules), + }) +} + +pub struct TranslationFields { + pub name: TokenStream, + pub description: TokenStream, + pub documentation: TokenStream, + pub deprecation_message: TokenStream, + pub display_message: TokenStream, + pub alias: TokenStream, +} + +pub fn translation_vec(entries: &[(String, String)], hercules: &TokenStream) -> TokenStream { + let items = entries + .iter() + .map(|(code, content)| quote!(#hercules::Translation::new(#code, #content))); + quote!(vec![#(#items),*]) +} + +pub fn optional_translation_vec( + args: &AttrArgs, + key: &str, + hercules: &TokenStream, +) -> syn::Result { + let entries = args.translations(key)?; + if entries.is_empty() { + return Ok(quote!(None)); + } + let vec = translation_vec(&entries, hercules); + Ok(quote!(Some(#vec))) +} + +pub fn optional_string(value: syn::Result>) -> syn::Result { + Ok(match value? { + Some(s) => quote!(Some(#s.to_string())), + None => quote!(None), + }) +} + +pub fn hercules_path() -> TokenStream { + quote!(::hercules) +} diff --git a/rust/hercules-macros/src/runtime_event.rs b/rust/hercules-macros/src/runtime_event.rs new file mode 100644 index 0000000..d1523e8 --- /dev/null +++ b/rust/hercules-macros/src/runtime_event.rs @@ -0,0 +1,60 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::ItemStruct; + +use crate::items::setting_tokens; +use crate::parse::{hercules_path, optional_string, take_repeated, translation_fields, AttrArgs}; + +pub fn expand(attr: TokenStream, item: TokenStream) -> syn::Result { + let mut item_struct: ItemStruct = syn::parse2(item)?; + let args = AttrArgs::parse(attr)?; + let hercules = hercules_path(); + + let settings = take_repeated(&mut item_struct, "setting")? + .iter() + .map(|s| setting_tokens(s, &hercules)) + .collect::>>()?; + + let identifier = args.required_string("identifier")?; + let signature = args.required_string("signature")?; + let t = translation_fields(&args, &hercules)?; + let display_icon = optional_string(args.string("display_icon"))?; + let editable = args.flag("editable"); + let omit_definition = args.flag("omit_definition"); + let (name, description, documentation, display_message, alias) = ( + t.name, + t.description, + t.documentation, + t.display_message, + t.alias, + ); + + let ident = &item_struct.ident; + Ok(quote! { + #item_struct + + impl #hercules::RuntimeEvent for #ident { + fn meta() -> #hercules::RuntimeEventMeta { + #hercules::RuntimeEventMeta { + identifier: #identifier.to_string(), + signature: #signature.to_string(), + settings: vec![#(#settings),*], + editable: #editable, + name: #name, + description: #description, + documentation: #documentation, + display_message: #display_message, + alias: #alias, + display_icon: #display_icon, + omit_definition: #omit_definition, + } + } + } + + #hercules::inventory::submit! { + #hercules::Registration(|action| { + action.register_runtime_event::<#ident>(); + }) + } + }) +} diff --git a/rust/hercules-macros/src/runtime_function.rs b/rust/hercules-macros/src/runtime_function.rs new file mode 100644 index 0000000..ddb2974 --- /dev/null +++ b/rust/hercules-macros/src/runtime_function.rs @@ -0,0 +1,91 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Fields, ItemStruct}; + +use crate::items::parameter_tokens; +use crate::parse::{hercules_path, optional_string, take_repeated, translation_fields, AttrArgs}; + +pub fn expand(attr: TokenStream, item: TokenStream) -> syn::Result { + let mut item_struct: ItemStruct = syn::parse2(item)?; + let args = AttrArgs::parse(attr)?; + let hercules = hercules_path(); + + let parameters = take_repeated(&mut item_struct, "parameter")? + .iter() + .map(|p| parameter_tokens(p, &hercules)) + .collect::>>()?; + + let identifier = args.required_string("identifier")?; + let signature = args.required_string("signature")?; + let t = translation_fields(&args, &hercules)?; + let display_icon = optional_string(args.string("display_icon"))?; + let design = optional_string(args.string("design"))?; + let throws_error = args.flag("throws_error"); + let omit_definition = args.flag("omit_definition"); + let (name, description, documentation, deprecation_message, display_message, alias) = ( + t.name, + t.description, + t.documentation, + t.deprecation_message, + t.display_message, + t.alias, + ); + + let ident = &item_struct.ident; + let meta_impl = quote! { + impl #hercules::RuntimeFunction for #ident { + fn meta() -> #hercules::RuntimeFunctionMeta { + #hercules::RuntimeFunctionMeta { + identifier: #identifier.to_string(), + signature: #signature.to_string(), + name: #name, + description: #description, + documentation: #documentation, + deprecation_message: #deprecation_message, + display_message: #display_message, + alias: #alias, + display_icon: #display_icon, + design: #design, + throws_error: #throws_error, + omit_definition: #omit_definition, + parameters: vec![#(#parameters),*], + } + } + } + }; + + // `manual`: this handler needs constructor-injected state a macro has no + // way to conjure up (a DB pool, a client, ...) — register it yourself + // via `Action::register_runtime_function(instance)` instead. + if args.flag("manual") { + return Ok(quote! { + #item_struct + #meta_impl + }); + } + + // A unit struct has no fields to default-construct, so the common case + // (stateless handlers, by far most of them) gets `Default` for free + // instead of requiring `#[derive(Default)]` on every single one. + let default_impl = matches!(item_struct.fields, Fields::Unit).then(|| { + quote! { + impl ::core::default::Default for #ident { + fn default() -> Self { + #ident + } + } + } + }); + + Ok(quote! { + #item_struct + #meta_impl + #default_impl + + #hercules::inventory::submit! { + #hercules::Registration(|action| { + action.register_runtime_function(<#ident as ::core::default::Default>::default()); + }) + } + }) +} diff --git a/rust/hercules/Cargo.toml b/rust/hercules/Cargo.toml new file mode 100644 index 0000000..286a8d3 --- /dev/null +++ b/rust/hercules/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "hercules" +description = "Rust SDK for the hercules action runner" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +hercules-macros = { path = "../hercules-macros", version = "0.0.0" } +tucana.workspace = true +tokio.workspace = true +tonic.workspace = true +async-trait.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +schemars.workspace = true +tokio-stream.workspace = true +log.workspace = true +inventory.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/rust/hercules/src/action.rs b/rust/hercules/src/action.rs new file mode 100644 index 0000000..732c507 --- /dev/null +++ b/rust/hercules/src/action.rs @@ -0,0 +1,238 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::broadcast; +use tokio_stream::Stream; +use tucana::shared::Module; + +use crate::connected::{spawn_dispatch_loop, Connected, ConnectedInner, RuntimeFunctionEntry}; +use crate::connection; +use crate::data_type::{self, DataType, DataTypeDef}; +use crate::error::Result; +use crate::event::{Event, RuntimeEvent}; +use crate::events::{event_stream, HerculesEvent}; +use crate::function::{Function, RuntimeFunction}; +use crate::meta::{ + merge_event, merge_event_unchecked, merge_function, merge_function_unchecked, EventDef, + FunctionDef, RuntimeEventMeta, +}; +use crate::module_builder::{build_module, ModuleBuildData}; +use crate::registration; +use crate::types::{ConfigurationDefinition, Translation}; + +const EVENT_CHANNEL_CAPACITY: usize = 256; + +/// An action under construction: `#[hercules::runtime_function]` and friends +/// register themselves automatically (see [`crate::registration`]), so most +/// actions need nothing beyond `Action::new(...)` plus a handful of chained +/// setters. [`Action::register_runtime_function`] and friends remain for the +/// cases auto-registration can't cover — chiefly a `RuntimeFunction` whose +/// handler needs constructor-injected state. +/// +/// This is a plain, single-owner builder that turns into a cheaply cloneable +/// [`Connected`] handle via [`Action::connect`]. That split exists because +/// after connecting, a background task reads the stream concurrently with +/// anything the caller does; the type change makes that boundary an +/// ownership transfer the compiler enforces, rather than a rule callers have +/// to remember on their own. +pub struct Action { + identifier: String, + version: String, + aquila_url: Option, + author: String, + icon: String, + documentation: String, + name: Vec, + configuration_definitions: Vec, + + functions: HashMap, + runtime_functions: HashMap, + data_types: HashMap, + events: HashMap, + runtime_events: HashMap, + + events_tx: broadcast::Sender, +} + +impl Action { + /// Only `identifier` and `version` are required; everything else has a + /// sensible empty default and can be set via the chained setters below. + /// Every `#[hercules::...]`-annotated item linked into the binary is + /// registered automatically as part of this call. + pub fn new(identifier: impl Into, version: impl Into) -> Self { + let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let mut action = Self { + identifier: identifier.into(), + version: version.into(), + aquila_url: None, + author: String::new(), + icon: String::new(), + documentation: String::new(), + name: Vec::new(), + configuration_definitions: Vec::new(), + functions: HashMap::new(), + runtime_functions: HashMap::new(), + data_types: HashMap::new(), + events: HashMap::new(), + runtime_events: HashMap::new(), + events_tx, + }; + registration::apply_all(&mut action); + action + } + + pub fn aquila_url(mut self, url: impl Into) -> Self { + self.aquila_url = Some(url.into()); + self + } + + pub fn author(mut self, author: impl Into) -> Self { + self.author = author.into(); + self + } + + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = icon.into(); + self + } + + pub fn documentation(mut self, documentation: impl Into) -> Self { + self.documentation = documentation.into(); + self + } + + pub fn name(mut self, name: impl IntoIterator) -> Self { + self.name = name.into_iter().collect(); + self + } + + pub fn configuration(mut self, definition: ConfigurationDefinition) -> Self { + self.configuration_definitions.push(definition); + self + } + + pub fn identifier(&self) -> &str { + &self.identifier + } + + pub fn version(&self) -> &str { + &self.version + } + + /// A live feed of connection/execution lifecycle events. Can be called + /// before or after [`Action::connect`] — every subscriber gets its own + /// view of the same underlying broadcast. + pub fn subscribe(&self) -> impl Stream + Send + 'static { + event_stream(&self.events_tx) + } + + /// Registers a runtime function together with the handler instance that + /// runs it. Unless its `meta()` sets `omit_definition`, it is also + /// published as a same-named public [`Function`]. + /// + /// Only needed for handlers `#[hercules::runtime_function(manual)]` + /// opted out of auto-registration for — typically because they carry + /// injected state a macro has no way to construct on its own. + pub fn register_runtime_function(&mut self, instance: T) { + let meta = T::meta(); + let entry = RuntimeFunctionEntry { + meta: meta.clone(), + handler: Arc::new(instance), + }; + let omit_definition = meta.omit_definition; + self.runtime_functions + .insert(meta.identifier.clone(), entry); + + if !omit_definition { + let identifier = meta.identifier.clone(); + let def = merge_function_unchecked(meta, Default::default()); + self.functions.insert(identifier, def); + } + } + + /// Registers a public [`Function`] that overrides metadata (identifier, + /// parameter defaults, ...) on top of its `Base` [`RuntimeFunction`]. + /// The base must already be registered via [`Action::register_runtime_function`] + /// — execution always dispatches through it, never through `F` itself. + pub fn register_function(&mut self) -> Result<()> { + let def = merge_function(F::Base::meta(), F::meta(), std::any::type_name::())?; + self.functions.insert(def.runtime_name.clone(), def); + Ok(()) + } + + pub fn register_data_type(&mut self) -> Result<()> { + let def = data_type::resolve::(T::meta())?; + self.data_types.insert(def.identifier.clone(), def); + Ok(()) + } + + /// Registers a runtime event. Unless its `meta()` sets `omit_definition`, + /// it is also published as a same-named public [`Event`]. + pub fn register_runtime_event(&mut self) { + let meta = T::meta(); + let omit_definition = meta.omit_definition; + self.runtime_events + .insert(meta.identifier.clone(), meta.clone()); + + if !omit_definition { + let identifier = meta.identifier.clone(); + let def = merge_event_unchecked(meta, Default::default()); + self.events.insert(identifier, def); + } + } + + /// Registers a public [`Event`] on top of its `Base` [`RuntimeEvent`], + /// analogous to [`Action::register_function`]. + pub fn register_event(&mut self) -> Result<()> { + let def = merge_event(E::Base::meta(), E::meta(), std::any::type_name::())?; + self.events.insert(def.identifier.clone(), def); + Ok(()) + } + + pub fn build_module(&self) -> Module { + build_module(ModuleBuildData { + identifier: &self.identifier, + version: &self.version, + author: &self.author, + icon: &self.icon, + documentation: &self.documentation, + name: &self.name, + configuration_definitions: &self.configuration_definitions, + data_types: self.data_types.values().collect(), + events: self.events.values().collect(), + runtime_events: self.runtime_events.values().collect(), + functions: self.functions.values().collect(), + runtime_functions: self.runtime_functions.values().map(|e| &e.meta).collect(), + }) + } + + /// Opens the connection to Aquila and starts dispatching incoming + /// execution requests. Consumes the builder; the returned [`Connected`] + /// is a cheap-to-clone handle usable from any task. + pub async fn connect( + self, + auth_token: impl Into, + aquila_url: Option, + ) -> Result { + let module = self.build_module(); + let url = aquila_url + .or(self.aquila_url) + .ok_or(crate::error::HerculesError::MissingAquilaUrl)?; + + let connection = connection::connect(module, &auth_token.into(), &url).await?; + + let inner = Arc::new(ConnectedInner { + identifier: self.identifier, + version: self.version, + runtime_functions: self.runtime_functions, + configs: Default::default(), + request_tx: connection.request_tx, + events_tx: self.events_tx, + }); + + let _ = inner.events_tx.send(HerculesEvent::Connected); + spawn_dispatch_loop(inner.clone(), connection.responses); + + Ok(Connected::new(inner)) + } +} diff --git a/rust/hercules/src/arguments.rs b/rust/hercules/src/arguments.rs new file mode 100644 index 0000000..481dcb3 --- /dev/null +++ b/rust/hercules/src/arguments.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use serde::de::DeserializeOwned; + +use crate::error::{HerculesError, Result}; +use crate::types::PlainValue; + +/// Named, typed access to a function's incoming parameters. +/// +/// The wire sends parameters as a name -> value map (a protobuf `Struct`), +/// so this keeps that shape rather than flattening it into a positional +/// list: `args.get::("n")?` is both self-documenting at the call site +/// and decodes directly off the incoming map, with no need to first +/// re-order values into declaration order the way an index-based API would. +#[derive(Debug, Clone, Default)] +pub struct Arguments(HashMap); + +impl Arguments { + pub(crate) fn new(values: HashMap) -> Self { + Self(values) + } + + /// Decodes the named parameter as `T`. + /// + /// Fails with [`HerculesError::MissingParameter`] if the caller didn't + /// supply it at all (distinct from having explicitly supplied `null`), + /// or [`HerculesError::Json`] if it doesn't decode as `T`. + pub fn get(&self, name: &str) -> Result { + let value = self + .0 + .get(name) + .ok_or_else(|| HerculesError::MissingParameter(name.to_string()))?; + Ok(serde_json::from_value(value.clone())?) + } + + /// The raw, undecoded value, if the caller supplied one. + pub fn raw(&self, name: &str) -> Option<&PlainValue> { + self.0.get(name) + } +} diff --git a/rust/hercules/src/connected.rs b/rust/hercules/src/connected.rs new file mode 100644 index 0000000..d5d3cb4 --- /dev/null +++ b/rust/hercules/src/connected.rs @@ -0,0 +1,261 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::{Stream, StreamExt}; +use tonic::Streaming; +use tucana::aquila::{ + action_transfer_request, action_transfer_response, ActionEvent, ActionExecutionRequest, + ActionExecutionResponse, ActionTransferRequest, ActionTransferResponse, +}; +use tucana::shared::{node_execution_result, Error as WireError, NodeExecutionResult}; + +use crate::arguments::Arguments; +use crate::error::{HerculesError, Result}; +use crate::events::{event_stream, HerculesEvent}; +use crate::function::RuntimeFunctionHandler; +use crate::meta::RuntimeFunctionMeta; +use crate::sync; +use crate::types::{FunctionContext, PlainValue, ProjectConfiguration}; +use crate::value::{construct_value, to_allowed_value}; + +pub(crate) struct RuntimeFunctionEntry { + pub meta: RuntimeFunctionMeta, + pub handler: Arc, +} + +pub(crate) struct ConnectedInner { + pub identifier: String, + pub version: String, + pub runtime_functions: HashMap, + pub configs: RwLock>, + pub request_tx: mpsc::UnboundedSender, + pub events_tx: broadcast::Sender, +} + +/// A live, connected action. Cheap to clone (an `Arc` underneath) and safe to +/// share across tasks: [`Connected::fire`] and reads all go through shared +/// state that the background stream-reader task also touches under a lock. +#[derive(Clone)] +pub struct Connected { + inner: Arc, +} + +impl Connected { + pub(crate) fn new(inner: Arc) -> Self { + Self { inner } + } + + pub fn identifier(&self) -> &str { + &self.inner.identifier + } + + pub fn version(&self) -> &str { + &self.inner.version + } + + pub fn subscribe(&self) -> impl Stream + Send + 'static { + event_stream(&self.inner.events_tx) + } + + /// The configuration Aquila has resolved for `project_id`, if any has + /// been pushed down yet. + pub fn config(&self, project_id: i64) -> Option { + sync::read(&self.inner.configs).get(&project_id).cloned() + } + + /// Sends an event payload for `project_id` up to Aquila. + pub fn fire( + &self, + event_type: impl Into, + project_id: i64, + payload: PlainValue, + ) -> Result<()> { + let request = ActionTransferRequest { + data: Some(action_transfer_request::Data::Event(ActionEvent { + event_type: event_type.into(), + project_id, + payload: Some(construct_value(&payload)), + })), + }; + send(&self.inner, request) + } +} + +fn send(inner: &ConnectedInner, request: ActionTransferRequest) -> Result<()> { + log::trace!("sending {request:?}"); + inner + .request_tx + .send(request) + .map_err(|_| HerculesError::StreamClosed) +} + +pub(crate) fn spawn_dispatch_loop( + inner: Arc, + mut responses: Streaming, +) { + tokio::spawn(async move { + while let Some(message) = responses.next().await { + match message { + Ok(response) => handle_response(&inner, response), + Err(status) => { + let _ = inner.events_tx.send(HerculesEvent::Error(Arc::new( + HerculesError::Status(status), + ))); + } + } + } + }); +} + +fn handle_response(inner: &Arc, response: ActionTransferResponse) { + log::trace!("received {response:?}"); + + match response.data { + Some(action_transfer_response::Data::ModuleConfigurations(configurations)) => { + let mut configs = sync::write(&inner.configs); + configs.clear(); + for project in &configurations.module_configurations { + let config_values = project + .module_configurations + .iter() + .map(|c| { + ( + c.identifier.clone(), + c.value + .clone() + .map(to_allowed_value) + .unwrap_or(PlainValue::Null), + ) + }) + .collect(); + configs.insert( + project.project_id, + ProjectConfiguration { + project_id: project.project_id, + config_values, + }, + ); + } + drop(configs); + let _ = inner + .events_tx + .send(HerculesEvent::ModuleUpdated(Arc::new(configurations))); + } + Some(action_transfer_response::Data::Execution(execution)) => { + let _ = inner + .events_tx + .send(HerculesEvent::ExecutionRequestReceived(Arc::new( + execution.clone(), + ))); + tokio::spawn(handle_execution(inner.clone(), execution)); + } + None => { + let _ = inner + .events_tx + .send(HerculesEvent::Error(Arc::new(HerculesError::Other( + "received unknown message type from stream".into(), + )))); + } + } +} + +fn now_micros() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_micros() as i64) + .unwrap_or_default() +} + +async fn handle_execution(inner: Arc, execution: ActionExecutionRequest) { + let Some(entry) = inner.runtime_functions.get(&execution.function_identifier) else { + log::error!( + "execution request for unknown function {:?}", + execution.function_identifier + ); + return; + }; + + // The wire already sends parameters as a name -> value map; pass that + // straight through instead of re-flattening it into declaration order. + let args = Arguments::new( + execution + .parameters + .map(|s| { + s.fields + .into_iter() + .map(|(k, v)| (k, to_allowed_value(v))) + .collect() + }) + .unwrap_or_default(), + ); + + let matched_config = sync::read(&inner.configs) + .get(&execution.project_id) + .cloned() + .unwrap_or(ProjectConfiguration { + project_id: execution.project_id, + config_values: HashMap::new(), + }); + let context = FunctionContext { + project_id: execution.project_id, + execution_id: execution.execution_identifier.clone(), + matched_config, + }; + + let started_at = now_micros(); + let outcome = entry.handler.run(&context, &args).await; + let finished_at = now_micros(); + + let result = match outcome { + Ok(value) => node_execution_result::Result::Success(construct_value(&value)), + Err(HerculesError::Runtime { code, description }) => { + node_execution_result::Result::Error(wire_error( + code, + description.unwrap_or_default(), + finished_at, + &inner.version, + )) + } + Err(other) => node_execution_result::Result::Error(wire_error( + "UNKNOWN_ERROR".into(), + other.to_string(), + finished_at, + &inner.version, + )), + }; + + let request = ActionTransferRequest { + data: Some(action_transfer_request::Data::Result( + ActionExecutionResponse { + execution_identifier: execution.execution_identifier, + node_result: Some(NodeExecutionResult { + started_at, + finished_at, + parameter_results: vec![], + id: None, + result: Some(result), + }), + }, + )), + }; + if send(&inner, request).is_err() { + log::error!( + "failed to send execution result for {:?}: stream closed", + context.execution_id + ); + } +} + +fn wire_error(code: String, message: String, timestamp: i64, version: &str) -> WireError { + WireError { + code, + category: "RUNTIME".into(), + message, + timestamp, + version: version.to_string(), + dependencies: HashMap::new(), + details: None, + } +} diff --git a/rust/hercules/src/connection.rs b/rust/hercules/src/connection.rs new file mode 100644 index 0000000..ed8edce --- /dev/null +++ b/rust/hercules/src/connection.rs @@ -0,0 +1,59 @@ +//! Opens the bidirectional `ActionTransferService.Transfer` stream to Aquila +//! and sends the initial `ActionLogon` frame. + +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tonic::metadata::MetadataValue; +use tonic::transport::Endpoint; +use tonic::{Request, Streaming}; +use tucana::aquila::action_transfer_service_client::ActionTransferServiceClient; +use tucana::aquila::{ + action_transfer_request, ActionLogon, ActionTransferRequest, ActionTransferResponse, +}; +use tucana::shared::Module; + +use crate::error::{HerculesError, Result}; + +pub struct Connection { + pub request_tx: mpsc::UnboundedSender, + pub responses: Streaming, +} + +/// Plain gRPC, no TLS — Aquila is expected to sit behind a trusted network +/// boundary rather than be reachable directly. +fn endpoint_uri(aquila_url: &str) -> String { + if aquila_url.starts_with("http://") || aquila_url.starts_with("https://") { + aquila_url.to_string() + } else { + format!("http://{aquila_url}") + } +} + +pub async fn connect(module: Module, auth_token: &str, aquila_url: &str) -> Result { + let channel = Endpoint::from_shared(endpoint_uri(aquila_url))? + .connect() + .await?; + let mut client = ActionTransferServiceClient::new(channel); + + let (request_tx, request_rx) = mpsc::unbounded_channel::(); + request_tx + .send(ActionTransferRequest { + data: Some(action_transfer_request::Data::Logon(ActionLogon { + module: Some(module), + })), + }) + .map_err(|_| HerculesError::StreamClosed)?; + + let mut request = Request::new(UnboundedReceiverStream::new(request_rx)); + let token: MetadataValue<_> = auth_token + .parse() + .map_err(|_| HerculesError::Other("auth token is not valid gRPC metadata".to_string()))?; + request.metadata_mut().insert("authorization", token); + + let responses = client.transfer(request).await?.into_inner(); + + Ok(Connection { + request_tx, + responses, + }) +} diff --git a/rust/hercules/src/data_type.rs b/rust/hercules/src/data_type.rs new file mode 100644 index 0000000..37b1b43 --- /dev/null +++ b/rust/hercules/src/data_type.rs @@ -0,0 +1,285 @@ +//! Data types are derived from a Rust type's [`schemars::JsonSchema`] impl: +//! `#[derive(JsonSchema)]` gives us the structural shape, and `schemars`' +//! own validation attributes (`#[schemars(regex(...))]`, +//! `#[schemars(range(...))]`) give us the validation rules Aquila enforces, +//! both for free. +//! +//! Aquila's `DefinitionDataType.type` field holds a small structural-type +//! string (`"string"`, `"{ id: number }"`, `"NUMBER"` for a reference to +//! another registered type), not a JSON Schema document, so [`type_string`] +//! renders the generated JSON Schema down into that syntax. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use schemars::{JsonSchema, Schema, SchemaGenerator}; +use serde_json::Value as Json; + +use crate::error::{HerculesError, Result}; +use crate::meta::DataTypeMeta; +use crate::sync; +use crate::types::Translation; + +/// A custom data type an action contributes to the module's vocabulary. +/// Implemented by deriving `schemars::JsonSchema` and attaching +/// `#[hercules::data_type(identifier = "...")]`, which generates `meta()`. +pub trait DataType: JsonSchema + Send + Sync + 'static { + fn meta() -> DataTypeMeta; +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DataTypeRule { + Regex(String), + NumberRange { from: i64, to: i64 }, +} + +/// Fully-resolved data type, ready to be sent to Aquila. +#[derive(Debug, Clone)] +pub struct DataTypeDef { + pub identifier: String, + pub name: Vec, + pub display_message: Vec, + pub alias: Vec, + pub generic_keys: Vec, + pub r#type: String, + pub rules: Vec, +} + +/// Maps a `schemars` schema name (`T::schema_name()`) to the hercules +/// identifier it was registered under, so `$ref`s between data types in the +/// same action resolve to their public identifier instead of their Rust type +/// name. Populated by [`resolve`] each time `Action::register_data_type` runs. +fn registry() -> &'static RwLock> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(Default::default) +} + +/// Builds the [`DataTypeDef`] for `T`, registering it so later data types +/// that embed `T` as a field render a reference to `meta.identifier` instead +/// of expanding its structure inline. +pub fn resolve(meta: DataTypeMeta) -> Result { + sync::write(registry()).insert(T::schema_name().into_owned(), meta.identifier.clone()); + + let schema = SchemaGenerator::default().into_root_schema_for::(); + let defs = schema + .get("$defs") + .and_then(Json::as_object) + .cloned() + .unwrap_or_default(); + + Ok(DataTypeDef { + r#type: type_string(&schema, &defs, &meta.identifier, true)?, + rules: rules(&schema), + identifier: meta.identifier, + name: meta.name, + display_message: meta.display_message, + alias: meta.alias, + generic_keys: meta.generic_keys, + }) +} + +fn rules(schema: &Schema) -> Vec { + let mut rules = Vec::new(); + if let Some(pattern) = schema.get("pattern").and_then(Json::as_str) { + rules.push(DataTypeRule::Regex(pattern.to_string())); + } + if let (Some(from), Some(to)) = ( + schema.get("minimum").and_then(json_as_i64), + schema.get("maximum").and_then(json_as_i64), + ) { + rules.push(DataTypeRule::NumberRange { from, to }); + } + rules +} + +fn json_as_i64(v: &Json) -> Option { + v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)) +} + +/// Renders a JSON Schema value as Aquila's structural-type string. `root` is +/// true only for the schema's own top level, so a self-referential `$ref` +/// there still expands its real structure once instead of immediately +/// collapsing to its own identifier. +fn type_string( + schema: &Schema, + defs: &serde_json::Map, + identifier: &str, + root: bool, +) -> Result { + if let Some(reference) = schema.get("$ref").and_then(Json::as_str) { + let name = reference.rsplit('/').next().unwrap_or(reference); + if !root { + if let Some(target) = sync::read(registry()).get(name) { + return Ok(target.clone()); + } + } + let def = defs + .get(name) + .ok_or_else(|| HerculesError::RecursiveDataType { + identifier: identifier.to_string(), + })?; + let nested = + Schema::try_from(def.clone()).map_err(|_| HerculesError::RecursiveDataType { + identifier: identifier.to_string(), + })?; + return type_string(&nested, defs, identifier, false); + } + + if let Some(variants) = schema + .get("anyOf") + .or_else(|| schema.get("oneOf")) + .and_then(Json::as_array) + { + // Keep the variants in the order `schemars` generated them rather + // than sorting: the value variant(s) come before `null`, so this + // renders as `string | null` rather than `null | string`. + let mut parts = Vec::with_capacity(variants.len()); + for variant in variants { + let variant = Schema::try_from(variant.clone()).unwrap_or_default(); + parts.push(type_string(&variant, defs, identifier, false)?); + } + return Ok(parts.join(" | ")); + } + + // `Option` fields render as a JSON Schema type *array* + // (`"type": ["string", "null"]`) rather than `anyOf`. + if let Some(types) = schema.get("type").and_then(Json::as_array) { + let parts: Vec<&str> = types + .iter() + .filter_map(Json::as_str) + .map(primitive_name) + .collect(); + return Ok(parts.join(" | ")); + } + + let ty = schema.get("type").and_then(Json::as_str).unwrap_or(""); + match ty { + "string" | "integer" | "number" | "boolean" | "null" => Ok(primitive_name(ty).to_string()), + "array" => { + let items = schema + .get("items") + .and_then(|v| Schema::try_from(v.clone()).ok()) + .unwrap_or_default(); + Ok(format!( + "{}[]", + type_string(&items, defs, identifier, false)? + )) + } + "object" => { + let Some(properties) = schema.get("properties").and_then(Json::as_object) else { + // No fixed field set: either an open map (`additionalProperties` + // set, e.g. a `HashMap`) or a genuinely empty struct. + return match schema + .get("additionalProperties") + .and_then(|v| Schema::try_from(v.clone()).ok()) + { + Some(value_schema) => Ok(format!( + "Record", + type_string(&value_schema, defs, identifier, false)? + )), + None => Ok("{}".into()), + }; + }; + if properties.is_empty() { + return Ok("{}".into()); + } + let required: Vec<&str> = schema + .get("required") + .and_then(Json::as_array) + .into_iter() + .flatten() + .filter_map(Json::as_str) + .collect(); + + // `properties` preserves the struct's field declaration order + // (the `preserve_order` feature enabled on `serde_json`), so + // fields render in the same order they're declared in the struct. + let mut fields = Vec::with_capacity(properties.len()); + for (key, value) in properties { + let field_schema = Schema::try_from(value.clone()).unwrap_or_default(); + let field_type = type_string(&field_schema, defs, identifier, false)?; + fields.push(if required.contains(&key.as_str()) { + format!("{key}: {field_type}") + } else { + format!("{key}?: {field_type} | undefined") + }); + } + Ok(format!("{{ {}; }}", fields.join("; "))) + } + _ => Ok("unknown".into()), + } +} + +/// Renders a JSON Schema primitive `type` keyword in Aquila's type-string syntax. +fn primitive_name(ty: &str) -> &'static str { + match ty { + "string" => "string", + "integer" | "number" => "number", + "boolean" => "boolean", + "null" => "null", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + + use super::*; + + #[derive(Serialize, Deserialize, JsonSchema)] + struct Many { + zebra: String, + apple: f64, + mango: bool, + nick: Option, + } + + #[derive(Serialize, Deserialize, JsonSchema)] + struct Empty {} + + fn root_type_string() -> String { + let schema = SchemaGenerator::default().into_root_schema_for::(); + let defs = schema + .get("$defs") + .and_then(Json::as_object) + .cloned() + .unwrap_or_default(); + type_string(&schema, &defs, "test", true).unwrap() + } + + // Pins the exact rendered syntax for each schema shape, so a future + // change to `type_string` can't silently drift the wire format Aquila + // receives. + #[test] + fn object_field_order_and_optional_markers() { + assert_eq!( + root_type_string::(), + "{ zebra: string; apple: number; mango: boolean; nick?: string | null | undefined; }" + ); + } + + #[test] + fn empty_object_renders_as_empty_braces() { + assert_eq!(root_type_string::(), "{}"); + } + + #[test] + fn open_map_renders_as_record() { + assert_eq!( + root_type_string::>(), + "Record" + ); + } + + #[test] + fn array_renders_with_bracket_suffix() { + assert_eq!(root_type_string::>(), "string[]"); + } + + #[test] + fn nullable_root_renders_as_union_with_null() { + assert_eq!(root_type_string::>(), "string | null"); + } +} diff --git a/rust/hercules/src/error.rs b/rust/hercules/src/error.rs new file mode 100644 index 0000000..07f19b1 --- /dev/null +++ b/rust/hercules/src/error.rs @@ -0,0 +1,70 @@ +use thiserror::Error; + +/// Everything that can go wrong in the SDK: connection failures, malformed +/// registrations, and errors a [`crate::function::RuntimeFunctionHandler`] +/// reports back to Aquila. +#[derive(Debug, Error)] +pub enum HerculesError { + /// A function handler's own reported failure. Distinct from other + /// variants so it round-trips through the wire `Error` message with a + /// caller-chosen `code` instead of the generic `"UNKNOWN_ERROR"`. + #[error("runtime error {code}{}", description.as_deref().map(|d| format!(": {d}")).unwrap_or_default())] + Runtime { + code: String, + description: Option, + }, + + #[error("{0}")] + Other(String), + + #[error("aquila_url must be provided in Action::new or Action::connect")] + MissingAquilaUrl, + + #[error(transparent)] + Transport(#[from] tonic::transport::Error), + + #[error(transparent)] + Status(#[from] tonic::Status), + + /// Decoding a parameter or configuration value with `serde_json` failed + /// — lets a [`crate::function::RuntimeFunctionHandler`] use `?` when + /// pulling typed arguments out of [`crate::Arguments`]. + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error("missing required parameter {0:?}")] + MissingParameter(String), + + #[error("failed to send on the Aquila request stream")] + StreamClosed, + + #[error( + "cannot generate a type string for data type {identifier:?}: it contains a recursive \ + schema that can't be inlined. Register the nested schema as its own data type so it \ + can be referenced by identifier instead." + )] + RecursiveDataType { identifier: String }, + + #[error("runtime function {class} has a parameter {parameter:?} that isn't declared on its runtime function")] + UnknownParameter { + class: &'static str, + parameter: String, + }, + + #[error("event {class} has a setting {setting:?} that isn't declared on its runtime event")] + UnknownSetting { + class: &'static str, + setting: String, + }, +} + +impl HerculesError { + pub fn runtime(code: impl Into, description: impl Into>) -> Self { + Self::Runtime { + code: code.into(), + description: description.into(), + } + } +} + +pub type Result = std::result::Result; diff --git a/rust/hercules/src/event.rs b/rust/hercules/src/event.rs new file mode 100644 index 0000000..5fe8445 --- /dev/null +++ b/rust/hercules/src/event.rs @@ -0,0 +1,23 @@ +use crate::meta::{EventMeta, RuntimeEventMeta}; + +/// The internal definition of an event/flow-type an action can fire. +/// Attach `#[hercules::runtime_event(identifier = "...", signature = "...")]` +/// to a (typically unit) struct to implement this via `meta()`. +/// +/// Unlike [`crate::function::RuntimeFunction`], events carry no behavior — +/// firing one is just sending a payload (see [`crate::Connected::fire`]) — +/// so this trait only needs a type to hang the macro-generated `meta()` off +/// of. +pub trait RuntimeEvent: Send + Sync + 'static { + fn meta() -> RuntimeEventMeta + where + Self: Sized; +} + +/// A public, user-facing variant of a [`RuntimeEvent`], analogous to +/// [`crate::function::Function`] for functions. +pub trait Event: Send + Sync + 'static { + type Base: RuntimeEvent; + + fn meta() -> EventMeta; +} diff --git a/rust/hercules/src/events.rs b/rust/hercules/src/events.rs new file mode 100644 index 0000000..0c4a712 --- /dev/null +++ b/rust/hercules/src/events.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use tokio::sync::broadcast; +use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream}; +use tokio_stream::{Stream, StreamExt}; +use tucana::aquila::ActionExecutionRequest; +use tucana::shared::ModuleConfigurations; + +use crate::error::HerculesError; + +/// Application-level lifecycle events, broadcast to every subscriber (see +/// [`crate::Action::subscribe`] / [`crate::Connected::subscribe`]). Raw wire +/// traffic (every request sent, every response received) is *not* part of +/// this enum — subscribing to that would force every caller to pay for +/// cloning and routing messages they'll never look at. It's emitted via +/// `log::trace!` instead, which costs nothing when that level is disabled +/// (the common case) and needs no subscriber code to access +/// (`RUST_LOG=hercules=trace`). +#[derive(Debug, Clone)] +pub enum HerculesEvent { + Connected, + Error(Arc), + ModuleUpdated(Arc), + ExecutionRequestReceived(Arc), +} + +/// Wraps a broadcast receiver as a [`Stream`], the idiomatic way to expose a +/// multi-consumer event feed in async Rust — callers get `StreamExt` +/// combinators for free instead of hand-rolling a `recv().await` loop. +/// +/// A slow subscriber can be lagged by [`crate::events`]' bounded channel; +/// rather than that terminating the stream (a `while let Ok(...) = rx.recv()` +/// loop silently stops forever on the first lag), lagged gaps are logged and +/// skipped so the stream keeps running. +pub(crate) fn event_stream( + tx: &broadcast::Sender, +) -> impl Stream + Send + 'static { + BroadcastStream::new(tx.subscribe()).filter_map(|item| match item { + Ok(event) => Some(event), + Err(BroadcastStreamRecvError::Lagged(skipped)) => { + log::warn!("event subscriber lagged, dropped {skipped} event(s)"); + None + } + }) +} diff --git a/rust/hercules/src/function.rs b/rust/hercules/src/function.rs new file mode 100644 index 0000000..179c44d --- /dev/null +++ b/rust/hercules/src/function.rs @@ -0,0 +1,36 @@ +use async_trait::async_trait; + +use crate::arguments::Arguments; +use crate::error::Result; +use crate::meta::{FunctionMeta, RuntimeFunctionMeta}; +use crate::types::{FunctionContext, PlainValue}; + +/// The actual behavior of a runtime function. Split out from +/// [`RuntimeFunction`] so it can be boxed as `Arc` +/// for dynamic dispatch when an execution request comes in off the wire — +/// `RuntimeFunction::meta()` alone can't be object-safe since it's an +/// associated function with no `self`. +#[async_trait] +pub trait RuntimeFunctionHandler: Send + Sync + 'static { + async fn run(&self, context: &FunctionContext, args: &Arguments) -> Result; +} + +/// A function Aquila can invoke. Implemented by attaching +/// `#[hercules::runtime_function(identifier = "...", signature = "...")]` to +/// a struct, which generates `meta()`; the struct separately implements +/// [`RuntimeFunctionHandler`] with the actual logic. +pub trait RuntimeFunction: RuntimeFunctionHandler { + fn meta() -> RuntimeFunctionMeta + where + Self: Sized; +} + +/// A public, user-facing variant of a [`RuntimeFunction`] — same execution +/// behavior, but its own identifier/signature/parameter defaults. It carries +/// no behavior of its own (dispatch always resolves through `Base`), only +/// metadata overrides merged onto the base via [`crate::meta::merge_function`]. +pub trait Function: Send + Sync + 'static { + type Base: RuntimeFunction; + + fn meta() -> FunctionMeta; +} diff --git a/rust/hercules/src/lib.rs b/rust/hercules/src/lib.rs new file mode 100644 index 0000000..002f5b9 --- /dev/null +++ b/rust/hercules/src/lib.rs @@ -0,0 +1,84 @@ +//! Rust SDK for building hercules actions that connect to Aquila. +//! +//! Attaching `#[hercules::runtime_function]` (or `data_type`, `event`, ...) +//! to a type is enough to register it — see [`registration`] — so most +//! actions need nothing beyond a handful of chained setters: +//! +//! ```no_run +//! use hercules::{Action, Arguments, FunctionContext, RuntimeFunction, RuntimeFunctionHandler, async_trait}; +//! +//! #[hercules::runtime_function(identifier = "greet", signature = "(name: TEXT): TEXT")] +//! struct Greet; +//! +//! #[async_trait] +//! impl RuntimeFunctionHandler for Greet { +//! async fn run(&self, _ctx: &FunctionContext, args: &Arguments) -> hercules::Result { +//! let name: String = args.get("name")?; +//! Ok(format!("Hello, {name}!").into()) +//! } +//! } +//! +//! # async fn run() -> hercules::Result<()> { +//! let action = Action::new("example", "0.1.0").author("me").icon("tabler:bolt"); +//! let action = action.connect("token", Some("127.0.0.1:8081".into())).await?; +//! action.fire("greeted", 1, serde_json::json!({ "name": "world" }))?; +//! # Ok(()) +//! # } +//! ``` + +mod action; +mod arguments; +mod connected; +mod connection; +mod data_type; +mod error; +mod event; +mod events; +mod function; +mod meta; +mod module_builder; +mod registration; +mod sync; +mod types; +mod value; + +pub use action::Action; +pub use arguments::Arguments; +pub use connected::Connected; +pub use data_type::{DataType, DataTypeDef, DataTypeRule}; +pub use error::{HerculesError, Result}; +pub use event::{Event, RuntimeEvent}; +pub use events::HerculesEvent; +pub use function::{Function, RuntimeFunction, RuntimeFunctionHandler}; +pub use meta::DataTypeMeta; +pub use meta::{ + EventDef, EventMeta, EventSettingMeta, FunctionDef, FunctionMeta, ParameterMeta, + RuntimeEventMeta, RuntimeFunctionMeta, +}; +pub use registration::Registration; +pub use types::{ + ConfigurationDefinition, FunctionContext, PlainValue, ProjectConfiguration, Translation, + UniquenessScope, +}; + +/// Re-exported so `#[hercules::runtime_function]`-annotated types can +/// implement `RuntimeFunctionHandler` without adding `async-trait` to their +/// own `Cargo.toml`. +pub use async_trait::async_trait; +/// Re-exported so macro-generated code can submit a [`Registration`] without +/// requiring `inventory` as a direct dependency of the consuming crate. +pub use inventory; +/// Re-exported for deriving [`DataType`] schemas: `#[derive(hercules::JsonSchema)]`. +pub use schemars::JsonSchema; +/// Re-exported so macro-generated code can build [`PlainValue`]s (`serde_json::json!`) +/// without requiring `serde_json` as a direct dependency of the consuming crate. +pub use serde_json; + +pub use hercules_macros::{data_type, event, function, runtime_event, runtime_function}; + +/// Re-export of the wire types this SDK sends/receives, for callers who want +/// to inspect [`HerculesEvent`] payloads in full. +pub mod wire { + pub use tucana::aquila::*; + pub use tucana::shared::{Module, ModuleConfigurations}; +} diff --git a/rust/hercules/src/meta.rs b/rust/hercules/src/meta.rs new file mode 100644 index 0000000..bc64504 --- /dev/null +++ b/rust/hercules/src/meta.rs @@ -0,0 +1,275 @@ +//! The metadata shapes produced by `#[hercules::...]` attribute macros, and +//! the merge rules that combine a `Function`/`Event`'s overrides with the +//! `RuntimeFunction`/`RuntimeEvent` they extend. +//! +//! Each `#[hercules::runtime_function]` / `#[hercules::function]` / ... macro +//! generates a `meta()` associated function that builds one of these shapes +//! once, at compile time — there's no runtime reflection or registry lookup +//! involved in producing it. + +use crate::error::{HerculesError, Result}; +use crate::types::{PlainValue, Translation, UniquenessScope}; + +/// One parameter of a (runtime) function. Also doubles as an *override* when +/// it appears on a [`crate::function::Function`]: a parameter with the same +/// `runtime_name` as one on the base [`crate::function::RuntimeFunction`] +/// fully replaces it rather than merging field by field. +#[derive(Debug, Clone, Default)] +pub struct ParameterMeta { + pub runtime_name: String, + /// The parameter name on the runtime function this refers to, if it + /// differs from `runtime_name` (lets a public `Function` rename a + /// parameter it re-exposes). + pub runtime_definition_name: Option, + pub name: Vec, + pub description: Vec, + pub documentation: Vec, + pub default_value: Option, + pub hidden: bool, + pub optional: bool, +} + +/// One setting of a (runtime) event. See [`ParameterMeta`] for the override +/// semantics on [`crate::event::Event`]. +#[derive(Debug, Clone, Default)] +pub struct EventSettingMeta { + pub identifier: String, + pub unique: UniquenessScope, + pub linked_data_type_identifiers: Vec, + pub default_value: Option, + pub name: Vec, + pub description: Vec, + pub hidden: bool, + pub optional: bool, +} + +/// Fully-resolved metadata for a [`crate::function::RuntimeFunction`]. +#[derive(Debug, Clone, Default)] +pub struct RuntimeFunctionMeta { + pub identifier: String, + pub signature: String, + pub name: Vec, + pub description: Vec, + pub documentation: Vec, + pub deprecation_message: Vec, + pub display_message: Vec, + pub alias: Vec, + pub display_icon: Option, + pub design: Option, + pub throws_error: bool, + /// If set, this runtime function is never published as a public + /// [`crate::function::Function`] on its own — only via an explicit + /// `#[hercules::function(base = ...)]` type. + pub omit_definition: bool, + pub parameters: Vec, +} + +/// Overrides declared on a [`crate::function::Function`]; every field falls +/// back to the value on its `RuntimeFunction` base when absent. +#[derive(Debug, Clone, Default)] +pub struct FunctionMeta { + pub identifier: Option, + pub signature: Option, + pub name: Option>, + pub description: Option>, + pub documentation: Option>, + pub deprecation_message: Option>, + pub display_message: Option>, + pub alias: Option>, + pub display_icon: Option, + pub design: Option, + pub throws_error: Option, + pub parameters: Vec, +} + +/// A [`FunctionMeta`] merged onto its base [`RuntimeFunctionMeta`] — the +/// shape actually sent to Aquila. +#[derive(Debug, Clone)] +pub struct FunctionDef { + pub runtime_definition_name: String, + pub runtime_name: String, + pub signature: String, + pub throws_error: bool, + pub name: Vec, + pub description: Vec, + pub documentation: Vec, + pub deprecation_message: Vec, + pub display_message: Vec, + pub alias: Vec, + pub display_icon: Option, + pub design: Option, + pub parameters: Vec, +} + +/// Merges a `Function`'s overrides onto its `RuntimeFunction` base: unset +/// fields fall back to the base, and parameters are matched by +/// `runtime_name` (an override fully replaces the base parameter; +/// unmentioned base parameters pass through unchanged). +pub fn merge_function( + base: RuntimeFunctionMeta, + over: FunctionMeta, + class: &'static str, +) -> Result { + for p in &over.parameters { + if !base + .parameters + .iter() + .any(|bp| bp.runtime_name == p.runtime_name) + { + return Err(HerculesError::UnknownParameter { + class, + parameter: p.runtime_name.clone(), + }); + } + } + Ok(merge_function_unchecked(base, over)) +} + +/// The same merge as [`merge_function`], without validating that `over`'s +/// parameters actually exist on `base`. Used to publish a +/// [`RuntimeFunction`] as its own same-named [`crate::function::Function`] +/// with an empty [`FunctionMeta`] — there are no overrides to validate, so +/// there is nothing to fail. +pub(crate) fn merge_function_unchecked( + base: RuntimeFunctionMeta, + over: FunctionMeta, +) -> FunctionDef { + let mut parameters = over.parameters; + for bp in &base.parameters { + if !parameters.iter().any(|p| p.runtime_name == bp.runtime_name) { + parameters.push(bp.clone()); + } + } + for p in &mut parameters { + if p.runtime_definition_name.is_none() { + p.runtime_definition_name = Some(p.runtime_name.clone()); + } + } + + FunctionDef { + runtime_definition_name: base.identifier.clone(), + runtime_name: over.identifier.unwrap_or(base.identifier), + signature: over.signature.unwrap_or(base.signature), + throws_error: over.throws_error.unwrap_or(base.throws_error), + name: over.name.unwrap_or(base.name), + description: over.description.unwrap_or(base.description), + documentation: over.documentation.unwrap_or(base.documentation), + deprecation_message: over.deprecation_message.unwrap_or(base.deprecation_message), + display_message: over.display_message.unwrap_or(base.display_message), + alias: over.alias.unwrap_or(base.alias), + display_icon: over.display_icon.or(base.display_icon), + design: over.design.or(base.design), + parameters, + } +} + +/// Fully-resolved metadata for a [`crate::event::RuntimeEvent`]. +#[derive(Debug, Clone, Default)] +pub struct RuntimeEventMeta { + pub identifier: String, + pub signature: String, + pub settings: Vec, + pub editable: bool, + pub name: Vec, + pub description: Vec, + pub documentation: Vec, + pub display_message: Vec, + pub alias: Vec, + pub display_icon: Option, + /// If set, this runtime event is never published as a public + /// [`crate::event::Event`] on its own — mirrors + /// [`RuntimeFunctionMeta::omit_definition`]. + pub omit_definition: bool, +} + +/// Overrides declared on a [`crate::event::Event`]; see [`FunctionMeta`]. +#[derive(Debug, Clone, Default)] +pub struct EventMeta { + pub identifier: Option, + pub signature: Option, + pub settings: Vec, + pub editable: Option, + pub name: Option>, + pub description: Option>, + pub documentation: Option>, + pub display_message: Option>, + pub alias: Option>, + pub display_icon: Option, +} + +/// An [`EventMeta`] merged onto its base [`RuntimeEventMeta`]. +#[derive(Debug, Clone)] +pub struct EventDef { + pub runtime_identifier: String, + pub identifier: String, + pub signature: String, + pub settings: Vec, + pub editable: bool, + pub name: Vec, + pub description: Vec, + pub documentation: Vec, + pub display_message: Vec, + pub alias: Vec, + pub display_icon: Option, +} + +/// Merges an `Event`'s overrides onto its `RuntimeEvent` base. Unlike +/// parameters, settings always come from the base (in its order); the +/// `Event` may only override individual fields per identifier (e.g. give a +/// setting a default value). +pub fn merge_event( + base: RuntimeEventMeta, + over: EventMeta, + class: &'static str, +) -> Result { + for s in &over.settings { + if !base.settings.iter().any(|bs| bs.identifier == s.identifier) { + return Err(HerculesError::UnknownSetting { + class, + setting: s.identifier.clone(), + }); + } + } + Ok(merge_event_unchecked(base, over)) +} + +/// The same merge as [`merge_event`], without validating that `over`'s +/// settings actually exist on `base`. See [`merge_function_unchecked`]. +pub(crate) fn merge_event_unchecked(base: RuntimeEventMeta, over: EventMeta) -> EventDef { + let settings = base + .settings + .iter() + .map(|bs| { + over.settings + .iter() + .find(|s| s.identifier == bs.identifier) + .cloned() + .unwrap_or_else(|| bs.clone()) + }) + .collect(); + + EventDef { + runtime_identifier: base.identifier.clone(), + identifier: over.identifier.unwrap_or(base.identifier), + signature: over.signature.unwrap_or(base.signature), + settings, + editable: over.editable.unwrap_or(base.editable), + name: over.name.unwrap_or(base.name), + description: over.description.unwrap_or(base.description), + documentation: over.documentation.unwrap_or(base.documentation), + display_message: over.display_message.unwrap_or(base.display_message), + alias: over.alias.unwrap_or(base.alias), + display_icon: over.display_icon.or(base.display_icon), + } +} + +/// Metadata for a [`crate::data_type::DataType`]. Unlike functions/events, +/// data types have no base to extend. +#[derive(Debug, Clone, Default)] +pub struct DataTypeMeta { + pub identifier: String, + pub name: Vec, + pub display_message: Vec, + pub alias: Vec, + pub generic_keys: Vec, +} diff --git a/rust/hercules/src/module_builder.rs b/rust/hercules/src/module_builder.rs new file mode 100644 index 0000000..6c80e46 --- /dev/null +++ b/rust/hercules/src/module_builder.rs @@ -0,0 +1,273 @@ +//! Assembles the registries on an [`crate::Action`] into the wire +//! `tucana::shared::Module` sent as the `ActionLogon` payload. + +use tucana::shared::{ + self, definition_data_type_rule, flow_type_setting, runtime_flow_type_setting, + DataTypeNumberRangeRuleConfig, DataTypeRegexRuleConfig, DefinitionDataType, + DefinitionDataTypeRule, FlowType, FlowTypeSetting, FunctionDefinition, Module, + ModuleConfigurationDefinition, ParameterDefinition, RuntimeFlowType, RuntimeFlowTypeSetting, + RuntimeFunctionDefinition, RuntimeParameterDefinition, +}; + +use crate::data_type::{DataTypeDef, DataTypeRule}; +use crate::meta::{ + EventDef, EventSettingMeta, FunctionDef, ParameterMeta, RuntimeEventMeta, RuntimeFunctionMeta, +}; +use crate::types::{ConfigurationDefinition, Translation, UniquenessScope}; +use crate::value::construct_value; + +pub struct ModuleBuildData<'a> { + pub identifier: &'a str, + pub version: &'a str, + pub author: &'a str, + pub icon: &'a str, + pub documentation: &'a str, + pub name: &'a [Translation], + pub configuration_definitions: &'a [ConfigurationDefinition], + pub data_types: Vec<&'a DataTypeDef>, + pub events: Vec<&'a EventDef>, + pub runtime_events: Vec<&'a RuntimeEventMeta>, + pub functions: Vec<&'a FunctionDef>, + pub runtime_functions: Vec<&'a RuntimeFunctionMeta>, +} + +fn translations(ts: &[Translation]) -> Vec { + ts.iter() + .map(|t| shared::Translation { + code: t.code.clone(), + content: t.content.clone(), + }) + .collect() +} + +fn scope_to_flow_type_setting(scope: UniquenessScope) -> flow_type_setting::UniquenessScope { + match scope { + UniquenessScope::None => flow_type_setting::UniquenessScope::None, + UniquenessScope::Project => flow_type_setting::UniquenessScope::Project, + } +} + +fn scope_to_runtime_flow_type_setting( + scope: UniquenessScope, +) -> runtime_flow_type_setting::UniquenessScope { + match scope { + UniquenessScope::None => runtime_flow_type_setting::UniquenessScope::None, + UniquenessScope::Project => runtime_flow_type_setting::UniquenessScope::Project, + } +} + +fn parameter_definition(p: &ParameterMeta) -> ParameterDefinition { + ParameterDefinition { + runtime_name: p.runtime_name.clone(), + runtime_definition_name: p + .runtime_definition_name + .clone() + .unwrap_or_else(|| p.runtime_name.clone()), + name: translations(&p.name), + description: translations(&p.description), + documentation: translations(&p.documentation), + hidden: Some(p.hidden), + optional: Some(p.optional), + default_value: p.default_value.as_ref().map(construct_value), + } +} + +fn runtime_parameter_definition(p: &ParameterMeta) -> RuntimeParameterDefinition { + RuntimeParameterDefinition { + runtime_name: p.runtime_name.clone(), + name: translations(&p.name), + description: translations(&p.description), + documentation: translations(&p.documentation), + hidden: Some(p.hidden), + optional: Some(p.optional), + default_value: p.default_value.as_ref().map(construct_value), + } +} + +fn flow_type_setting(s: &EventSettingMeta) -> FlowTypeSetting { + FlowTypeSetting { + identifier: s.identifier.clone(), + unique: scope_to_flow_type_setting(s.unique) as i32, + default_value: s.default_value.as_ref().map(construct_value), + name: translations(&s.name), + description: translations(&s.description), + optional: Some(s.optional), + hidden: Some(s.hidden), + } +} + +fn runtime_flow_type_setting(s: &EventSettingMeta) -> RuntimeFlowTypeSetting { + RuntimeFlowTypeSetting { + identifier: s.identifier.clone(), + unique: scope_to_runtime_flow_type_setting(s.unique) as i32, + default_value: s.default_value.as_ref().map(construct_value), + name: translations(&s.name), + description: translations(&s.description), + optional: Some(s.optional), + hidden: Some(s.hidden), + } +} + +fn data_type_rule(rule: &DataTypeRule) -> DefinitionDataTypeRule { + let config = match rule { + DataTypeRule::Regex(pattern) => { + definition_data_type_rule::Config::Regex(DataTypeRegexRuleConfig { + pattern: pattern.clone(), + }) + } + DataTypeRule::NumberRange { from, to } => { + definition_data_type_rule::Config::NumberRange(DataTypeNumberRangeRuleConfig { + from: *from, + to: *to, + steps: None, + }) + } + }; + DefinitionDataTypeRule { + config: Some(config), + } +} + +const DEFAULT_DISPLAY_ICON: &str = "tabler:note"; + +pub fn build_module(data: ModuleBuildData) -> Module { + Module { + identifier: data.identifier.to_string(), + version: data.version.to_string(), + author: data.author.to_string(), + icon: data.icon.to_string(), + documentation: data.documentation.to_string(), + name: translations(data.name), + description: vec![], + configurations: data + .configuration_definitions + .iter() + .map(|def| ModuleConfigurationDefinition { + identifier: def.identifier.clone(), + name: translations(&def.name), + description: translations(&def.description), + r#type: def.r#type.clone(), + linked_data_type_identifiers: def.linked_data_types.clone(), + default_value: def.default_value.as_ref().map(construct_value), + optional: Some(def.optional), + hidden: Some(def.hidden), + }) + .collect(), + definition_data_types: data + .data_types + .iter() + .map(|dt| DefinitionDataType { + identifier: dt.identifier.clone(), + name: translations(&dt.name), + alias: translations(&dt.alias), + rules: dt.rules.iter().map(data_type_rule).collect(), + generic_keys: dt.generic_keys.clone(), + r#type: dt.r#type.clone(), + linked_data_type_identifiers: vec![], + display_message: translations(&dt.display_message), + version: data.version.to_string(), + definition_source: "action".to_string(), + }) + .collect(), + flow_types: data + .events + .iter() + .map(|ft| FlowType { + identifier: ft.identifier.clone(), + settings: ft.settings.iter().map(flow_type_setting).collect(), + editable: ft.editable, + name: translations(&ft.name), + description: translations(&ft.description), + documentation: translations(&ft.documentation), + display_message: translations(&ft.display_message), + alias: translations(&ft.alias), + version: data.version.to_string(), + display_icon: ft + .display_icon + .clone() + .unwrap_or_else(|| DEFAULT_DISPLAY_ICON.to_string()), + definition_source: Some("action".to_string()), + linked_data_type_identifiers: vec![], + signature: ft.signature.clone(), + runtime_identifier: ft.runtime_identifier.clone(), + }) + .collect(), + runtime_flow_types: data + .runtime_events + .iter() + .map(|rft| RuntimeFlowType { + identifier: rft.identifier.clone(), + runtime_settings: rft.settings.iter().map(runtime_flow_type_setting).collect(), + editable: rft.editable, + name: translations(&rft.name), + description: translations(&rft.description), + documentation: translations(&rft.documentation), + display_message: translations(&rft.display_message), + alias: translations(&rft.alias), + version: data.version.to_string(), + display_icon: rft + .display_icon + .clone() + .unwrap_or_else(|| DEFAULT_DISPLAY_ICON.to_string()), + definition_source: Some("action".to_string()), + linked_data_type_identifiers: vec![], + signature: rft.signature.clone(), + }) + .collect(), + function_definitions: data + .functions + .iter() + .map(|f| FunctionDefinition { + runtime_name: f.runtime_name.clone(), + runtime_definition_name: f.runtime_definition_name.clone(), + parameter_definitions: f.parameters.iter().map(parameter_definition).collect(), + signature: f.signature.clone(), + throws_error: f.throws_error, + name: translations(&f.name), + description: translations(&f.description), + documentation: translations(&f.documentation), + deprecation_message: translations(&f.deprecation_message), + display_message: translations(&f.display_message), + alias: translations(&f.alias), + linked_data_type_identifiers: vec![], + display_icon: f + .display_icon + .clone() + .unwrap_or_else(|| DEFAULT_DISPLAY_ICON.to_string()), + version: data.version.to_string(), + definition_source: "action".to_string(), + design: f.design.clone(), + }) + .collect(), + runtime_function_definitions: data + .runtime_functions + .iter() + .map(|f| RuntimeFunctionDefinition { + runtime_name: f.identifier.clone(), + runtime_parameter_definitions: f + .parameters + .iter() + .map(runtime_parameter_definition) + .collect(), + signature: f.signature.clone(), + throws_error: f.throws_error, + name: translations(&f.name), + description: translations(&f.description), + documentation: translations(&f.documentation), + deprecation_message: translations(&f.deprecation_message), + display_message: translations(&f.display_message), + alias: translations(&f.alias), + linked_data_type_identifiers: vec![], + version: data.version.to_string(), + display_icon: f + .display_icon + .clone() + .unwrap_or_else(|| DEFAULT_DISPLAY_ICON.to_string()), + definition_source: "action".to_string(), + design: f.design.clone(), + }) + .collect(), + definitions: vec![], + definition_source: "action".to_string(), + } +} diff --git a/rust/hercules/src/registration.rs b/rust/hercules/src/registration.rs new file mode 100644 index 0000000..4af96da --- /dev/null +++ b/rust/hercules/src/registration.rs @@ -0,0 +1,23 @@ +//! Distributed registration, in the spirit of how Rocket auto-collects +//! routes: `#[hercules::runtime_function]` and friends each submit a +//! [`Registration`] via `inventory::submit!` as a side effect of the item +//! being *linked into the binary* — no explicit `action.register_*()` call +//! required. +//! +//! `RuntimeFunction`s that need constructor-injected state (a database pool, +//! an HTTP client, ...) can't be auto-constructed from nothing, so they opt +//! out with `#[hercules::runtime_function(..., manual)]` and get registered +//! explicitly via [`crate::Action::register_runtime_function`] instead. + +use crate::Action; + +#[doc(hidden)] +pub struct Registration(pub fn(&mut Action)); + +inventory::collect!(Registration); + +pub(crate) fn apply_all(action: &mut Action) { + for Registration(register) in inventory::iter::() { + register(action); + } +} diff --git a/rust/hercules/src/sync.rs b/rust/hercules/src/sync.rs new file mode 100644 index 0000000..bb255ab --- /dev/null +++ b/rust/hercules/src/sync.rs @@ -0,0 +1,17 @@ +//! Tiny wrappers around `std::sync::RwLock` that recover from lock +//! poisoning instead of panicking: a bug in one function handler or one +//! data type resolution shouldn't permanently wedge every other concurrent +//! request sharing the lock. The poisoning panic already happened elsewhere +//! and was reported there; there is nothing more to gain by panicking again +//! here. + +use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; + +pub fn read(lock: &RwLock) -> RwLockReadGuard<'_, T> { + lock.read().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +pub fn write(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + lock.write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/rust/hercules/src/types.rs b/rust/hercules/src/types.rs new file mode 100644 index 0000000..43f4063 --- /dev/null +++ b/rust/hercules/src/types.rs @@ -0,0 +1,116 @@ +//! Shared value types used across the SDK: translated strings, the value a +//! function payload is expressed as, and the context handed to a running +//! function. + +use std::fmt; + +/// A JSON-like value flowing across the wire (event payloads, function +/// parameters/results, configuration values). This is exactly the shape the +/// tucana `Struct`/`Value` protobuf messages carry, so we reuse +/// [`serde_json::Value`] instead of inventing a parallel enum: every +/// `serde_json` combinator (`json!`, `from_value`, `Deserialize`) works on it +/// for free. +pub type PlainValue = serde_json::Value; + +/// A single localized string, e.g. `Translation::new("en-US", "Add Numbers")`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Translation { + pub code: String, + pub content: String, +} + +impl Translation { + pub fn new(code: impl Into, content: impl Into) -> Self { + Self { + code: code.into(), + content: content.into(), + } + } +} + +impl fmt::Display for Translation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.content) + } +} + +/// The per-invocation state passed to a running [`crate::function::RuntimeFunctionHandler`]. +#[derive(Debug, Clone)] +pub struct FunctionContext { + pub project_id: i64, + pub execution_id: String, + pub matched_config: ProjectConfiguration, +} + +/// The configuration values Aquila has resolved for a single project, +/// keyed by identifier for O(1) lookup during execution. +#[derive(Debug, Clone, Default)] +pub struct ProjectConfiguration { + pub project_id: i64, + pub config_values: std::collections::HashMap, +} + +impl ProjectConfiguration { + /// Looks up a configured value by its `identifier`. + pub fn get(&self, identifier: &str) -> Option<&PlainValue> { + self.config_values.get(identifier) + } +} + +/// Describes a module-level configuration value an action expects the user +/// to fill in (e.g. an API key), declared once in [`crate::Action::new`]. +#[derive(Debug, Clone, Default)] +pub struct ConfigurationDefinition { + pub identifier: String, + pub r#type: String, + pub name: Vec, + pub description: Vec, + pub hidden: bool, + pub optional: bool, + pub linked_data_types: Vec, + pub default_value: Option, +} + +impl ConfigurationDefinition { + pub fn new(identifier: impl Into, r#type: impl Into) -> Self { + Self { + identifier: identifier.into(), + r#type: r#type.into(), + ..Default::default() + } + } + + pub fn name(mut self, name: impl IntoIterator) -> Self { + self.name = name.into_iter().collect(); + self + } + + pub fn description(mut self, description: impl IntoIterator) -> Self { + self.description = description.into_iter().collect(); + self + } + + pub fn optional(mut self, optional: bool) -> Self { + self.optional = optional; + self + } + + pub fn hidden(mut self, hidden: bool) -> Self { + self.hidden = hidden; + self + } + + pub fn default_value(mut self, value: impl Into) -> Self { + self.default_value = Some(value.into()); + self + } +} + +/// The uniqueness scope of an event/flow-type setting — how Aquila +/// deduplicates settings across a project. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UniquenessScope { + #[default] + None, + Project, +} diff --git a/rust/hercules/src/value.rs b/rust/hercules/src/value.rs new file mode 100644 index 0000000..c219795 --- /dev/null +++ b/rust/hercules/src/value.rs @@ -0,0 +1,16 @@ +//! Bridges [`crate::types::PlainValue`] (`serde_json::Value`) to the wire +//! `tucana::shared::Value` protobuf type, using the conversions `tucana` +//! already ships in `shared::helper::value` rather than reimplementing them. + +use tucana::shared::helper::value::{from_json_value, to_json_value}; +use tucana::shared::Value; + +use crate::types::PlainValue; + +pub fn construct_value(value: &PlainValue) -> Value { + from_json_value(value.clone()) +} + +pub fn to_allowed_value(value: Value) -> PlainValue { + to_json_value(value) +}