mae_macros 0.1.8

Procedural macros for Mae-Technologies micro-services — derive helpers, schema binding, and async test harness.
Documentation

mae_macros

Procedural macros for Mae-Technologies services.

For development rules, see DEVELOPMENT.md.


Macros


#[run_app]

Rewrites a single-expression function body into a complete async fn run<Context>(…) Actix-Web server setup.

What it does

Place #[run_app] on a function containing exactly one statement — typically a route configuration call. The macro expands it into a full server function that:

  • Creates a Redis session store via app::redis_session
  • Attaches TracingLogger, session middleware, and web::Data extractors for PgPool, ApplicationBaseUrl, HmacSecret, and the custom context type
  • Binds the HTTP server to a TcpListener and returns a Result<Server, anyhow::Error>

When to use it

Use #[run_app] in your service's main.rs or startup.rs to replace boilerplate Actix-Web server wiring. The annotated function's body becomes the .service(…) / .configure(…) call chain appended to the generated app.

Example

use mae_macros::run_app;

#[run_app]
fn configure(cfg: &mut web::ServiceConfig) {
    cfg.configure(routes::register)
}
// Expands to:
// async fn run<Context: Clone + Send + 'static>(
//     listener: TcpListener, db_pool: PgPool, base_url: String,
//     hmac_secret: SecretString, redis_uri: SecretString, custom_context: Context,
// ) -> Result<Server, anyhow::Error> { … }

#[schema]

Binds a struct to a Postgres schema and injects standard Mae repository columns.

What it does

#[schema(CtxType, "schema_name")] transforms the annotated struct into a full Mae repository by injecting standard audit columns (id, sys_client, status, comment, tags, sys_detail, created_by, updated_by, created_at, updated_at) and deriving MaeRepo, sqlx::FromRow, serde::Serialize, serde::Deserialize, Debug, and Clone. It also implements mae::repo::__private__::Build with the given schema name.

When to use it

Use #[schema] on every domain repository struct in Mae services. Define only the domain-specific fields; all standard columns are injected automatically.

Example

use mae_macros::schema;

#[schema(AppContext, "public")]
pub struct UserRepo {
    pub username: String,
    pub email: String,
}
// Expands to a struct with all standard Mae columns plus `username` and `email`.
// Also generates InsertRow, UpdateRow, Field, PatchField in the same module.

#[schema_root]

Like #[schema] but omits the auto-injected sys_client foreign-key column.

What it does

Identical to #[schema] in every respect, except the generated struct does not include the sys_client: i32 field. All other standard audit columns are still injected.

When to use it

Use #[schema_root] for the sys_client table itself (or any root entity that has no foreign-key back to sys_client).

Example

use mae_macros::schema_root;

#[schema_root(AppContext, "public")]
pub struct SysClientRepo {
    pub name: String,
    pub plan: String,
}
// Like #[schema] but without `sys_client: i32`.

#[derive(MaeRepo)]

Generates InsertRow, UpdateRow, Field, and PatchField types for a repository struct.

What it does

For each named field in the struct, MaeRepo emits:

  • InsertRow — plain struct of all non-#[locked], non-#[update_only] fields (used for SQL INSERT)
  • UpdateRow — struct of all non-#[locked], non-#[insert_only] fields wrapped in Option<T> (used for SQL UPDATE; None fields are omitted)
  • Field — enum with one variant per field plus All; Display impl emits the column name or a comma-separated list for SELECT
  • PatchField — typed enum carrying field values; convertible to FilterOp<Field> for partial-update WHERE clauses

Field attributes

Attribute Effect
#[locked] Excluded from both InsertRow and UpdateRow (server-managed: id, timestamps)
#[insert_only] Excluded from UpdateRow only (e.g. sys_client)
#[update_only] Excluded from InsertRow only

When to use it

MaeRepo is normally applied indirectly via #[schema] or #[schema_root]. Use it directly only when you need the generated types on a struct that doesn't follow the standard schema layout.

Example

use mae_macros::MaeRepo;

#[derive(MaeRepo, Debug, Clone, sqlx::FromRow)]
pub struct OrderRepo {
    #[locked]
    pub id: i32,
    pub customer_id: i32,
    pub total: i64,
}
// Generates: InsertRow { customer_id, total }
//            UpdateRow { customer_id: Option<i32>, total: Option<i64> }
//            Field { All, id, customer_id, total }
//            PatchField { customer_id(i32), total(i64) }

#[mae_test]

The standard test harness macro for async journey tests in Mae services.

What it does

#[mae_test] wraps an async fn test in a dedicated multi-threaded Tokio runtime and enforces Mae test-hygiene rules at compile time:

  • Forbids raw .unwrap(), .expect(), assert!, assert_eq!, and assert_ne! in test bodies. Use must::* helpers or ?-propagation instead.
  • Drives the async test body synchronously so it integrates cleanly with the standard #[test] harness (no #[tokio::test] needed).
  • Supports optional teardown (called after the test body, even on panic).
  • Supports optional docker gating — skip the test at compile time unless MAE_TESTCONTAINERS=1 was set in the environment.

When to use #[mae_test] vs plain #[test]

Scenario Use
Async test that hits a real DB / HTTP endpoint #[mae_test]
Test that needs a TestContext / Must helpers #[mae_test]
Pure synchronous unit test with no I/O #[test]
Quick in-process logic check #[test]

Rule of thumb: if your test is async, use #[mae_test].

Attribute arguments

Argument Effect
(none) Basic multi-threaded async test
docker Skip unless compiled with MAE_TESTCONTAINERS=1 cargo test
teardown = <path> Call the given async fn after the test body, even on panic

Arguments can be combined: #[mae_test(docker, teardown = crate::common::context::teardown)]


Examples

Good journey test — uses TestContext, Must, and ?

use mae_macros::mae_test;
use crate::common::{must::Must, context::TestContext};

#[mae_test]
async fn journey_create_user() -> Result<(), anyhow::Error> {
    let ctx = TestContext::new().await?;

    let user = ctx.users().create("alice").await?;
    must_eq(user.name, "alice");

    let fetched = ctx.users().get(user.id).await?;
    must_eq(fetched.id, user.id);

    Ok(())
}

Good docker-gated test with teardown

use mae_macros::mae_test;

#[mae_test(docker, teardown = crate::common::context::teardown)]
async fn journey_with_postgres() -> Result<(), anyhow::Error> {
    // Only runs when MAE_TESTCONTAINERS=1 cargo test is used.
    // `teardown` is called even if the test panics.
    let ctx = TestContext::new().await?;
    let result = ctx.some_db_op().await?;
    must_eq(result.status, "ok");
    Ok(())
}

Bad journey test — will not compile

use mae_macros::mae_test;

#[mae_test]
async fn bad_test() {
    // compile error: forbidden — use must_be_some() instead
    let x: Option<i32> = None;
    let _ = x.unwrap();

    // compile error: forbidden — return Result and use `?` instead
    assert_eq!(1 + 1, 2);
}

Running docker tests

# Run all tests (non-docker tests only):
cargo +nightly test

# Run all tests including docker-gated tests:
MAE_TESTCONTAINERS=1 cargo +nightly test --features test-utils

# Run a specific test:
MAE_TESTCONTAINERS=1 cargo +nightly test journey_with_postgres

Note: MAE_TESTCONTAINERS=1 is evaluated at compile time via option_env!. You must set it before / during the cargo test invocation, not just at runtime.


#[ignore] compatibility

#[mae_test] preserves all other attributes on the function, including #[ignore]:

#[mae_test]
#[ignore = "WIP: not ready yet"]
async fn pending_journey() -> Result<(), anyhow::Error> {
    Ok(())
}

Run ignored tests with cargo test -- --ignored.


Re-export path

#[mae_test] is re-exported from the mae crate's testing module (see mae::testing::mae_test). Services should import it via:

use mae::testing::mae_test;

or directly from mae_macros:

use mae_macros::mae_test;