Skip to content

feat(http-callout): add praxis-http-callout filter crate#730

Open
usize wants to merge 6 commits into
praxis-proxy:mainfrom
usize:feat/http-callout-filter
Open

feat(http-callout): add praxis-http-callout filter crate#730
usize wants to merge 6 commits into
praxis-proxy:mainfrom
usize:feat/http-callout-filter

Conversation

@usize

@usize usize commented Jun 27, 2026

Copy link
Copy Markdown
Member

This is the general purpose HTTP callout filter from #644, built on top of the CalloutClient landed in #663. It lets operators wire arbitrary HTTP guardrails services into a filter pipeline with just YAML config.

The filter makes an outbound HTTP request during request processing, extracts values from the response via JSONPath, and writes them to FilterResultSet for branch-chain evaluation. A Lakera Guardrail example is included.

Demo against Lakera Guard + local Ollama:
https://gist.github.com/usize/b3745f262ddc407df18b9793233b7c62

What's in the crate:

  • Config parsing with SSRF validation, env var ${VAR} expansion, duration strings
  • JSONPath extraction with type coercion (bool, number, string, array, object, null)
  • JSONPath body shaping for callout requests (e.g., Lakera doesn't want the model field)
  • Phase selection: request_headers (no body) or request_body (buffered)
  • Forward/inject headers, on-denied headers for rejections
  • Circuit breaker, depth limiting, fail-open/fail-closed modes
  • Auto-discovery via export_filters! + [package.metadata.praxis-filters]

Part of praxis-proxy/ai#97 and praxis-proxy/ai#76.

@usize usize requested review from a team June 27, 2026 02:47
@praxis-bot-app

Copy link
Copy Markdown

PR too large: 2052 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add skip/pr-conventions label to override.

@usize usize added the skip/pr-conventions Skip conventions checks for PRs label Jun 27, 2026
usize added 6 commits June 29, 2026 07:18
Create the praxis-proxy-http-callout satellite crate skeleton with
an empty export_filters! invocation and wire it into the workspace,
server, and integration test infrastructure.

- filter/http-callout/Cargo.toml with praxis-filters metadata marker
- filter/http-callout/src/lib.rs with empty export_filters!
- Workspace Cargo.toml: add member, serde_json_path dep, crate dep
- server/Cargo.toml: optional dep + http-callout feature (default-on)
- tests/integration/Cargo.toml: dev-dep + http-callout feature

Assisted by Opus 4.6

Signed-off-by: usize <mofoster@redhat.com>
Core implementation: config types with serde deserialization,
SSRF URL validation, env-var ${VAR} expansion in header values,
JSONPath extraction with type coercion, and the HttpCalloutFilter
struct with full HttpFilter trait implementation.

- config.rs: HttpCalloutConfig, TargetConfig, RequestConfig,
  ResponseConfig, ExtractionConfig, Phase enum, FailureModeConfig,
  CircuitBreakerConfig, duration parsing, SSRF validation,
  env-var expansion
- extract.rs: CompiledExtraction with JSONPath compilation at
  config time and runtime evaluation with coercion rules
  (bool→string, number→string, null→skip, array/object→JSON)
- lib.rs: HttpCalloutFilter with from_config factory, callout
  request building, response extraction, header injection,
  on-denied headers, and export_filters! registration

Assisted by Opus 4.6

Signed-off-by: usize <mofoster@redhat.com>
Comprehensive wiremock-based tests covering all filter features:

- Config parsing: valid config, missing target, invalid URL
  (no scheme, template), invalid JSONPath, env-var expansion
- SSRF validation: rejects non-http schemes, template URLs
- Phase handling: request_headers vs request_body body access
- Successful callout: 2xx → extraction → FilterResultSet
- JSONPath coercion: bool, number, string, null, no-match
- Failure modes: closed (reject) vs open (continue)
- Timeout: callout times out → failure mode applied
- Circuit breaker: trips after threshold
- Depth limiting: depth >= max_depth → failure mode applied
- Forward headers: downstream headers copied to callout
- Inject headers: callout response headers added to upstream
- Request body phase: fires on end_of_stream, skips on_request

46 unit tests total (23 config/extract + 23 filter integration).

Assisted by Opus 4.6

Signed-off-by: usize <mofoster@redhat.com>
Working Lakera Guard content safety config with branch-chain
rejection of flagged requests, plus end-to-end integration tests.

- examples/configs/ai/lakera-guard.yaml: callout to Lakera Guard
  API with JSONPath extraction of $.flagged, branch chain to
  static_response 403 on flagged=true, circuit breaker, fail-closed
- tests/integration/tests/suite/examples/lakera_guard.rs: two tests
  using mock Lakera backend (flagged→403, clean→200 with upstream)
- Wire lakera_guard module in mod.rs with http-callout feature gate
- Regenerated examples/README.md via sync-example-readme

Assisted by Opus 4.6

Signed-off-by: usize <mofoster@redhat.com>
Add a target.body config option that reshapes the downstream request
body before sending it to the callout target. Each key becomes a
field in the callout JSON body; each value is a JSONPath expression
evaluated against the downstream body.

This solves the impedance mismatch between strict callout APIs
(e.g. Lakera Guard rejects unknown fields) and upstream APIs
(e.g. Ollama requires "model" in the body). The downstream body
goes to the upstream untouched while the callout receives only
the mapped fields.

Also adds structured logging to the callout execution path:
debug for callout initiation, info for success/rejection,
warn for failures and body shaping errors, and debug for
extracted result values.

Example config:
  target:
    url: "https://api.lakera.ai/v2/guard"
    body:
      messages: "$.messages"

This sends {"messages":[...]} to Lakera while the full
{"model":"...","messages":[...]} body reaches the upstream.

Assisted by Opus 4.6

Signed-off-by: usize <mofoster@redhat.com>
The praxis-http-callout dependency is consumed via build.rs
auto-discovery codegen, not through a direct `use` in source.
cargo-machete doesn't see the generated include! and reports
it as unused.

Assisted by Opus 4.6

Signed-off-by: usize <mofoster@redhat.com>
@usize usize force-pushed the feat/http-callout-filter branch from 9aeb89e to 8c92b41 Compare June 29, 2026 14:37

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Adds praxis-http-callout filter crate for making outbound HTTP requests during filter pipeline processing, with JSONPath extraction and branch-chain integration.

Overall Assessment

Well-structured crate with clean separation (config/extract/lib), good test coverage across config parsing, phase handling, failure modes, body shaping, and circuit breaking. The integration test against the Lakera Guard example config exercises both flagged and clean paths end-to-end.

One correctness issue with on_denied_headers being unreachable, plus convention and coverage gaps.

Findings

Severity File Description
Large lib.rs:240 on_denied_headers is unreachable dead code
Medium config.rs:103 max_body_bytes has no upper bound
Medium lakera_guard.rs:64 Separator says "Helpers" instead of "Test Utilities"

CalloutResult::Rejected(rejection) => {
info!(url = %self.url, status = rejection.status, "callout rejected request");
Ok(self.build_rejection(None, rejection.status))
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Large] on_denied_headers is unreachable dead code. CalloutResult::Rejected carries only Rejection { status: u16 } — no response headers — and handle_result passes None to build_rejection here:

CalloutResult::Rejected(rejection) => {
    Ok(self.build_rejection(None, rejection.status))
},

So build_rejection never iterates on_denied_headers because response is always None in this path. The entire on_denied_headers field, parsing, and build_rejection header-injection loop are dead code.

To fix: either (a) extend CalloutResult::Rejected to carry the response headers so they can be forwarded here, or (b) remove on_denied_headers and build_rejection's response parameter until the callout client supports it, to avoid shipping a config option that silently does nothing.

pub phase: Phase,

/// Maximum request body bytes to buffer and forward.
#[serde(default = "default_max_body_bytes")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] max_body_bytes accepts any usize value with no upper bound. An operator typo (e.g. max_body_bytes: 10485760000) could cause unbounded memory allocation during body buffering. Add a #[serde(deserialize_with = ...)] or a post-deserialization check to cap the value at a reasonable maximum (e.g. 64 MiB), consistent with how other Praxis filters handle body size limits.

raw.contains("upstream ok"),
"clean request should receive upstream body"
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Project convention (CLAUDE.md) requires separator comments to say "Test Utilities", not "Helpers". Change this to:

// -----------------------------------------------------------------------------
// Test Utilities
// -----------------------------------------------------------------------------

@shaneutt shaneutt self-assigned this Jul 2, 2026
@shaneutt shaneutt added this to the v0.4.0 milestone Jul 2, 2026

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary: Adds the praxis-http-callout filter crate for outbound HTTP requests during pipeline processing with JSONPath extraction and branch-chain integration.

Overall Assessment

Solid new filter crate with clean module separation, comprehensive unit tests via wiremock, and a functional integration test. Three new findings beyond the prior review: an SSRF validation gap, and two test coverage gaps.

Findings

Severity File Description
Medium config.rs:263 validate_callout_url lacks SSRF checks for private/loopback IPs
Medium tests.rs No test for extraction when callout returns non-JSON body
Medium tests.rs No test for deny_unknown_fields rejection in config

// Env Var Expansion
// -----------------------------------------------------------------------------

/// Expand `${VAR}` references in a header value using environment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] validate_callout_url checks scheme and host presence but does not validate against private, loopback, or link-local IPs. The health check validator in core/src/config/validate/cluster/health_check.rs has an is_ssrf_sensitive() check that blocks loopback (127.x, ::1) and link-local (169.254.x.x — including the cloud metadata endpoint 169.254.169.254), with an insecure_options override.

Apply the same pattern here: resolve the host to an IP and reject SSRF-sensitive addresses by default, with an opt-in override for operators who intentionally target internal services. Without this, an operator misconfiguration could make the proxy call the cloud metadata service.

"extracted callout results"
);
} else {
warn!("callout response body is not valid JSON; skipping extraction");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] This non-JSON fallback path is untested. When extractions are configured but the callout returns a non-JSON body (e.g., text/plain), extraction is silently skipped and the request continues with no results written. Add a test that configures extractions but has the mock return a plain-text body, then assert FilterAction::Continue with an empty result set.

}

#[test]
fn config_non_http_scheme_rejected() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Missing test for #[serde(deny_unknown_fields)] rejection. All config structs use deny_unknown_fields but no test verifies it works. Add a test with a YAML config containing an unrecognized field (e.g., bogus_field: 42) and assert from_config returns an error mentioning the unknown field.

@shaneutt shaneutt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@usize sorry for the conflicts, but this will need to be re-worked for the core <-> AI split! Let me know if you need any help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip/pr-conventions Skip conventions checks for PRs

Projects

Development

Successfully merging this pull request may close these issues.

3 participants