feat(http-callout): add praxis-http-callout filter crate#730
Conversation
|
PR too large: 2052 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add |
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>
9aeb89e to
8c92b41
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
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)) | ||
| }, |
There was a problem hiding this comment.
[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")] |
There was a problem hiding this comment.
[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" | ||
| ); | ||
| } |
There was a problem hiding this comment.
[Medium] Project convention (CLAUDE.md) requires separator comments to say "Test Utilities", not "Helpers". Change this to:
// -----------------------------------------------------------------------------
// Test Utilities
// -----------------------------------------------------------------------------
praxis-bot
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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"); |
There was a problem hiding this comment.
[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() { |
There was a problem hiding this comment.
[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.
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:
modelfield)Part of praxis-proxy/ai#97 and praxis-proxy/ai#76.