Skip to content

Commit a98ff15

Browse files
committed
Add a new df.with_http_options helper.
Several of my tasks require passing additional options to `df.http` and `df.http_multipart`. Unfortunately, adding new parameters to these functions is not possible without breaking users who don't run `ALTER EXTENSION UPDATE`, even if those parameters use `pgrx::default!`. This is a new combinator function that manipulates the durofut JSON directly to add the options, for example, you'd use it like: ```sql df.with_http_options( df.http(...), '{"options": "here"}'::jsonb ); ``` This admittedly is less ergonomic than adding an `options =>` parameter for `df.http`, which is what my first pass at this did (see thomcc-work@79e26003 for my abandoned work along that approch). If we want, we could make this into an operator, e.g. allowing `df.http(...) <some-operator> '{"options": "here"}'` or something like that. I don't have strong feelings.
1 parent 77cc368 commit a98ff15

10 files changed

Lines changed: 407 additions & 22 deletions

File tree

USER_GUIDE.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -696,11 +696,31 @@ df.http(
696696
url TEXT, -- Required: endpoint URL
697697
method TEXT DEFAULT 'POST', -- GET, POST, PUT, DELETE, PATCH
698698
body TEXT DEFAULT NULL, -- Request body (JSON)
699-
headers JSONB DEFAULT '{}', -- Custom headers
699+
headers JSONB DEFAULT NULL, -- Custom headers
700700
timeout_seconds INT DEFAULT 30
701-
) RETURNS TEXT -- JSON response object
701+
) RETURNS TEXT -- JSON-encoded workflow node
702702
```
703703

704+
### df.with_http_options() Function
705+
706+
`df.with_http_options(fut TEXT, options JSONB) RETURNS TEXT` is the entry point for
707+
HTTP modifiers without changing either HTTP constructor's signature.
708+
709+
```sql
710+
df.with_http_options(df.http('https://api.github.com/', 'GET'), '{}'::jsonb)
711+
|=> 'response'
712+
```
713+
714+
In this version, only SQL `NULL` and an empty object (`{}`) are accepted as
715+
`options`; no option keys are supported yet. Other JSON values, including JSON
716+
`null`, are rejected. Empty options return the input text byte-for-byte.
717+
718+
The input must be a single `HTTP` or `HTTP_MULTIPART` node, optionally named with
719+
`|=>`. SQL nodes and compound graphs are rejected, so apply the helper before
720+
combining nodes. It does not execute a request or change HTTP permissions.
721+
An existing installation needs `ALTER EXTENSION pg_durable UPDATE` to add this
722+
helper; the original five-argument HTTP functions remain usable without it.
723+
704724
### Response Format
705725

706726
HTTP calls return a JSON object with full response details:
@@ -961,10 +981,10 @@ It needs the same `include_http => true` grant as `df.http()`.
961981
df.http_multipart(
962982
url TEXT, -- Required: endpoint URL
963983
method TEXT DEFAULT 'POST',
964-
parts JSONB DEFAULT '[]', -- Array of part objects
965-
headers JSONB DEFAULT '{}',
984+
parts JSONB DEFAULT NULL, -- Required: non-empty array of part objects
985+
headers JSONB DEFAULT NULL,
966986
timeout_seconds INT DEFAULT 30
967-
) RETURNS TEXT -- Same JSON envelope as df.http()
987+
) RETURNS TEXT -- JSON-encoded workflow node
968988
```
969989

970990
Each part is an object with `name` and `data_b64`, optionally `filename` (which makes it a

docs/api-reference.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,31 @@ Returns the same envelope as `df.http()`.
326326

327327
---
328328

329+
### df.with_http_options(fut, options)
330+
331+
HTTP-specific modifier entry point. Returns the JSON-encoded TEXT node for use in
332+
a workflow, not an HTTP response. Neither existing HTTP function changes signature.
333+
334+
| Parameter | Type | Auto-wrap | Description |
335+
|-----------|------|-----------|-------------|
336+
| `fut` | TEXT | ❌ Literal | A single `HTTP` or `HTTP_MULTIPART` node, optionally named with `\|=>` |
337+
| `options` | JSONB | ❌ Literal | SQL `NULL` or an empty object (`'{}'`) only in this version |
338+
339+
```sql
340+
df.with_http_options(df.http('https://api.github.com/', 'GET'), '{}'::jsonb)
341+
|=> 'response'
342+
```
343+
344+
No option keys are supported yet. Unknown keys, non-object JSON values (including
345+
JSON `null`), malformed nodes, SQL nodes, and compound graphs raise an error.
346+
SQL `NULL` and `{}` return the original node text byte-for-byte, preserving its
347+
config and result name. Apply the helper to each HTTP node before combining nodes.
348+
It neither resolves secrets nor grants HTTP access; activity-time permission and
349+
network checks still apply. Existing installations need `ALTER EXTENSION pg_durable
350+
UPDATE` to use this new helper, but not to keep using the original HTTP functions.
351+
352+
---
353+
329354
## Control Functions
330355

331356
### df.start(fut [, label] [, database] [, transaction_mode])

docs/http-security.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,31 @@ the node fails immediately.
107107
`execute_http` runs the following check before any network activity:
108108

109109
```sql
110-
SELECT has_function_privilege($submitted_by::regrole,
111-
'df.http(text,text,text,jsonb,integer)'::regprocedure,
112-
'EXECUTE')
110+
SELECT CASE
111+
WHEN p.oid IS NULL THEN 'absent'
112+
WHEN has_function_privilege($submitted_by::regrole, p.oid, 'EXECUTE') THEN 'allowed'
113+
ELSE 'denied'
114+
END
115+
FROM (SELECT to_regprocedure('df.http(text,text,text,jsonb,integer)') AS oid) p
113116
```
114117

118+
The original five-argument signature remains the privilege identity on both old
119+
and new schemas. `to_regprocedure()` lets a missing function fail with an explicit
120+
error instead of raising during lookup. Only `'allowed'` permits the request;
121+
`'absent'` and `'denied'` both fail the node. Multipart requests use the same check
122+
against `df.http_multipart(text,text,jsonb,jsonb,integer)`.
123+
115124
`has_function_privilege` honours PostgreSQL's standard privilege model:
116125
superusers always return `true`; regular roles return `true` only when an
117126
explicit `GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO <role>` (or a role that
118127
inherits one) is in effect.
119128

129+
`df.with_http_options(text,jsonb)` is a node modifier, not a network operation.
130+
Like other combinators, it uses ordinary `df` schema access and default PUBLIC
131+
`EXECUTE`. Wrapping a hand-crafted HTTP node does not bypass the activity's
132+
privilege check. No option keys are supported in this version; SQL `NULL` and
133+
`{}` preserve the original node text.
134+
120135
### 3.3 Managing access
121136

122137
HTTP access is **opt-in** and separate from general `df` access.

docs/upgrade-testing.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ gate, so they never need to be added to the exclude list.
203203
Each schema-changing PR should add a section here documenting what changed,
204204
what the upgrade script handles, and any backward compatibility considerations.
205205

206+
### v0.2.7 → v0.2.8
207+
208+
#### Add `df.with_http_options()` without changing the HTTP ABI
209+
- **DDL change:** Adds `df.with_http_options(fut text, options jsonb) RETURNS text`. The input must be a single `HTTP` or `HTTP_MULTIPART` node. No option keys are supported yet: SQL `NULL` and `{}` return the original node text byte-for-byte; other values and unsupported keys raise an error.
210+
- **Upgrade script:** [sql/pg_durable--0.2.7--0.2.8.sql](../sql/pg_durable--0.2.7--0.2.8.sql) adds only this helper. The existing HTTP functions, their grant/revoke helpers, OIDs, ACLs, and dependent objects are untouched. The new helper uses the same schema-access and default PUBLIC `EXECUTE` model as other combinators; it does not grant HTTP access.
211+
- **Scenario A considerations:** The added function matches pgrx-generated fresh-install SQL, including argument names, null handling and the `with_http_options_wrapper` C symbol.
212+
- **Scenario B1 considerations:** Both legacy HTTP wrappers retain their five-argument ABI. Merely changing the activity's `to_regprocedure()` lookup cannot make a six-argument C wrapper callable through a five-argument catalog entry. The B1 tests now invoke `df.http()` on every supported old schema (v0.2.2+) and `df.http_multipart()` where present (v0.2.5+), using both defaulted and explicit arguments without an extension update. The new helper remains absent until `ALTER EXTENSION UPDATE`, while existing HTTP construction continues to work. Missing catalog functions are still denied explicitly by the activity checks.
213+
- **Scenario B2 considerations:** No node-config or history-format changes. The upgrade tests verify that legacy HTTP OIDs and ACLs (including `WITH GRANT OPTION`) survive, that dependent views still work, and that the newly added helper preserves their node text.
214+
206215
### v0.2.6 → v0.2.7
207216

208217
#### Transaction-aware graph admission

scripts/test-upgrade.sh

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,26 @@ test_b1_dsl_construction() {
774774
assert_sql_contains "SELECT df.sql('SELECT 1');" '"node_type":"SQL"'
775775
}
776776

777+
test_b1_http_construction() {
778+
assert_sql_contains "SELECT df.http('https://api.github.com/');" '"node_type":"HTTP"' &&
779+
assert_sql_equals \
780+
"SELECT (df.http('https://api.github.com/', 'GET', NULL, NULL, 7)::jsonb->>'query')::jsonb->>'timeout_seconds';" \
781+
"7"
782+
}
783+
784+
test_b1_multipart_construction() {
785+
assert_sql_contains \
786+
"SELECT df.http_multipart('https://api.github.com/', parts => '[{\"name\":\"field\",\"data_b64\":\"aGk=\"}]'::jsonb);" \
787+
'"node_type":"HTTP_MULTIPART"' &&
788+
assert_sql_equals \
789+
"SELECT (df.http_multipart('https://api.github.com/', 'POST', '[{\"name\":\"field\",\"data_b64\":\"aGk=\"}]'::jsonb, NULL, 7)::jsonb->>'query')::jsonb->>'timeout_seconds';" \
790+
"7"
791+
}
792+
793+
test_b1_http_options_absent() {
794+
assert_sql_equals "SELECT to_regprocedure('df.with_http_options(text,jsonb)') IS NULL;" "t"
795+
}
796+
777797
test_b1_dsl_chain() {
778798
assert_sql_contains "SELECT df.sql('SELECT 1') ~> df.sql('SELECT 2');" '"node_type":"THEN"'
779799
}
@@ -904,6 +924,13 @@ else
904924
run_test "B1 [v${B1_VERSION}]: df.getvar()" test_b1_getvar
905925
run_test "B1 [v${B1_VERSION}]: df.version()" test_b1_version
906926
run_test "B1 [v${B1_VERSION}]: df.sql() construction" test_b1_dsl_construction
927+
run_test "B1 [v${B1_VERSION}]: df.http() legacy ABI and defaults" test_b1_http_construction
928+
if version_ge "$B1_VERSION" "0.2.5"; then
929+
run_test "B1 [v${B1_VERSION}]: df.http_multipart() legacy ABI and defaults" test_b1_multipart_construction
930+
fi
931+
if ! version_ge "$B1_VERSION" "0.2.8"; then
932+
run_test "B1 [v${B1_VERSION}]: new HTTP options helper remains absent" test_b1_http_options_absent
933+
fi
907934
run_test "B1 [v${B1_VERSION}]: DSL chain (~>)" test_b1_dsl_chain
908935
run_test "B1 [v${B1_VERSION}]: conditional operators (?>/!>)" test_b1_conditional_operators
909936
run_test "B1 [v${B1_VERSION}]: df.start()/wait_for_completion()" test_b1_start_and_complete
@@ -1017,12 +1044,62 @@ test_b2_grant_usage_after_upgrade() {
10171044
run_sql_capture "DROP OWNED BY ${probe_role}; DROP ROLE IF EXISTS ${probe_role};" >/dev/null 2>&1 || true
10181045
}
10191046

1047+
test_b2_http_api_after_upgrade() {
1048+
create_extension_at_version "$PREV_VERSION"
1049+
1050+
local output
1051+
output=$(run_sql_capture "
1052+
CREATE ROLE durable_b2_http_probe;
1053+
GRANT EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer),
1054+
df.http_multipart(text,text,jsonb,jsonb,integer)
1055+
TO durable_b2_http_probe WITH GRANT OPTION;
1056+
1057+
CREATE TEMP TABLE http_api_before AS
1058+
SELECT oid, proacl FROM pg_proc
1059+
WHERE oid IN (
1060+
'df.http(text,text,text,jsonb,integer)'::regprocedure,
1061+
'df.http_multipart(text,text,jsonb,jsonb,integer)'::regprocedure
1062+
);
1063+
CREATE TEMP VIEW http_calls_before AS
1064+
SELECT df.http('https://api.github.com/') AS http_node,
1065+
df.http_multipart('https://api.github.com/',
1066+
parts => '[{\"name\":\"field\",\"data_b64\":\"aGk=\"}]'::jsonb) AS multipart_node;
1067+
1068+
ALTER EXTENSION pg_durable UPDATE TO '${CURRENT_VERSION}';
1069+
1070+
DO \$verify\$
1071+
BEGIN
1072+
IF (SELECT count(*) FROM http_api_before) <> 2 OR EXISTS (
1073+
SELECT 1 FROM http_api_before AS previous
1074+
LEFT JOIN pg_proc AS current ON current.oid = previous.oid
1075+
WHERE current.oid IS NULL OR current.proacl IS DISTINCT FROM previous.proacl
1076+
) THEN
1077+
RAISE EXCEPTION 'HTTP function OIDs or ACLs changed during upgrade';
1078+
END IF;
1079+
IF NOT EXISTS (
1080+
SELECT 1 FROM http_calls_before
1081+
WHERE http_node::jsonb->>'node_type' = 'HTTP'
1082+
AND multipart_node::jsonb->>'node_type' = 'HTTP_MULTIPART'
1083+
AND df.with_http_options(http_node, '{}'::jsonb) = http_node
1084+
AND df.with_http_options(multipart_node, NULL) = multipart_node
1085+
) THEN
1086+
RAISE EXCEPTION 'Legacy HTTP calls or the additive helper failed after upgrade';
1087+
END IF;
1088+
END
1089+
\$verify\$;
1090+
1091+
DROP OWNED BY durable_b2_http_probe;
1092+
DROP ROLE durable_b2_http_probe;
1093+
") || { echo "$output"; return 1; }
1094+
}
1095+
10201096
if [ "$HAS_COMPAT_PREV" = true ]; then
10211097
run_test "B2: Pre-upgrade data survives ALTER EXTENSION UPDATE" test_b2_data_survives_upgrade
10221098
run_test "B2: Pre-upgrade instance remains queryable" test_b2_pre_upgrade_instance_after_upgrade
10231099
run_test "B2: In-flight work completes after upgrade" test_b2_inflight_work_after_upgrade
10241100
run_test "B2: New data and execution after upgrade" test_b2_new_data_after_upgrade
10251101
run_test "B2: df.grant_usage() works and df.debug_connection() is gone after upgrade" test_b2_grant_usage_after_upgrade
1102+
run_test "B2: HTTP OIDs, grants and dependent views survive upgrade" test_b2_http_api_after_upgrade
10261103
fi
10271104

10281105
# ============================================================================

sql/pg_durable--0.2.7--0.2.8.sql

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,10 @@
66
-- See docs/upgrade-testing.md for the upgrade-script and backward-compatibility
77
-- requirements (Scenario A / B1 / B2).
88
--
9-
-- No schema changes yet for 0.2.8. Add DDL below as the 0.2.8 cycle lands
10-
-- extension-schema changes.
9+
-- HTTP options are additive; existing function ABIs, OIDs and ACLs stay unchanged.
10+
CREATE FUNCTION df."with_http_options"(
11+
"fut" TEXT,
12+
"options" jsonb
13+
) RETURNS TEXT
14+
LANGUAGE c
15+
AS 'MODULE_PATHNAME', 'with_http_options_wrapper';

src/activities/execute_http.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,30 @@ pub const NAME: &str = "pg_durable::activity::execute-http";
2828
/// This closes the bypass path where a user crafts a raw Durofut JSON and
2929
/// passes it directly to `df.start()`, inserting an HTTP node without going
3030
/// through the DSL guard in `df.http()`.
31+
///
32+
/// A missing catalog function is denied explicitly instead of raising on lookup.
3133
async fn check_http_privilege(pool: &PgPool, submitted_by: &str) -> Result<(), String> {
32-
let has_priv: Option<bool> = sqlx::query_scalar(
33-
"SELECT has_function_privilege($1::regrole, \
34-
'df.http(text,text,text,jsonb,integer)'::regprocedure, \
35-
'EXECUTE')",
34+
let verdict: Option<String> = sqlx::query_scalar(
35+
"SELECT CASE \
36+
WHEN p.oid IS NULL THEN 'absent' \
37+
WHEN pg_catalog.has_function_privilege($1::regrole, p.oid, 'EXECUTE') THEN 'allowed' \
38+
ELSE 'denied' \
39+
END \
40+
FROM (SELECT \
41+
pg_catalog.to_regprocedure('df.http(text,text,text,jsonb,integer)') \
42+
AS oid) p",
3643
)
3744
.bind(submitted_by)
3845
.fetch_optional(pool)
3946
.await
4047
.map_err(|e| format!("HTTP privilege check failed for role '{submitted_by}': {e}"))?;
4148

42-
match has_priv {
43-
Some(true) => Ok(()),
49+
match verdict.as_deref() {
50+
Some("allowed") => Ok(()),
51+
Some("absent") => Err(format!(
52+
"Blocked: df.http() is not installed in this database, so the HTTP privilege \
53+
of role '{submitted_by}' cannot be verified."
54+
)),
4455
_ => Err(format!(
4556
"Blocked: role '{submitted_by}' does not have EXECUTE privilege on df.http(). \
4657
Grant EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer) TO {submitted_by} to allow HTTP requests."

src/activities/execute_multipart.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,29 @@ pub const NAME: &str = "pg_durable::activity::execute-multipart";
3232
/// Mirrors `execute_http::check_http_privilege` — closes the bypass path where
3333
/// a user crafts a raw Durofut JSON and passes it directly to `df.start()`,
3434
/// inserting an HTTP_MULTIPART node without going through the DSL guard.
35+
/// A missing catalog function is denied explicitly instead of raising on lookup.
3536
async fn check_multipart_privilege(pool: &PgPool, submitted_by: &str) -> Result<(), String> {
36-
let has_priv: Option<bool> = sqlx::query_scalar(
37-
"SELECT has_function_privilege($1::regrole, \
38-
'df.http_multipart(text,text,jsonb,jsonb,integer)'::regprocedure, \
39-
'EXECUTE')",
37+
let verdict: Option<String> = sqlx::query_scalar(
38+
"SELECT CASE \
39+
WHEN p.oid IS NULL THEN 'absent' \
40+
WHEN pg_catalog.has_function_privilege($1::regrole, p.oid, 'EXECUTE') THEN 'allowed' \
41+
ELSE 'denied' \
42+
END \
43+
FROM (SELECT \
44+
pg_catalog.to_regprocedure('df.http_multipart(text,text,jsonb,jsonb,integer)') \
45+
AS oid) p",
4046
)
4147
.bind(submitted_by)
4248
.fetch_optional(pool)
4349
.await
4450
.map_err(|e| format!("HTTP privilege check failed for role '{submitted_by}': {e}"))?;
4551

46-
match has_priv {
47-
Some(true) => Ok(()),
52+
match verdict.as_deref() {
53+
Some("allowed") => Ok(()),
54+
Some("absent") => Err(format!(
55+
"Blocked: df.http_multipart() is not installed in this database, so the multipart \
56+
HTTP privilege of role '{submitted_by}' cannot be verified."
57+
)),
4858
_ => Err(format!(
4959
"Blocked: role '{submitted_by}' does not have EXECUTE privilege on df.http_multipart(). \
5060
Grant EXECUTE ON FUNCTION df.http_multipart(text,text,jsonb,jsonb,integer) TO {submitted_by} to allow multipart HTTP requests."

src/dsl.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,46 @@ pub fn race(a: &str, b: &str) -> String {
459459
.to_json()
460460
}
461461

462+
/// Applies HTTP options to a single HTTP or HTTP_MULTIPART node.
463+
#[pg_extern(schema = "df")]
464+
pub fn with_http_options(fut: &str, options: Option<pgrx::JsonB>) -> String {
465+
let node = Durofut::try_from_json(fut).unwrap_or_else(|_| {
466+
pgrx::error!("df.with_http_options(): expected an HTTP or HTTP_MULTIPART node")
467+
});
468+
469+
if !matches!(node.node_type.as_str(), "HTTP" | "HTTP_MULTIPART")
470+
|| node.left_node.is_some()
471+
|| node.right_node.is_some()
472+
|| node.condition_node.is_some()
473+
|| !node.extra_nodes.is_empty()
474+
{
475+
pgrx::error!("df.with_http_options(): expected a single HTTP or HTTP_MULTIPART node");
476+
}
477+
478+
let config = node
479+
.query
480+
.as_deref()
481+
.and_then(|query| serde_json::from_str::<serde_json::Value>(query).ok());
482+
if !config.as_ref().is_some_and(serde_json::Value::is_object) {
483+
pgrx::error!("df.with_http_options(): HTTP node config must be a JSON object");
484+
}
485+
486+
if let Some(options) = options {
487+
let Some(map) = options.0.as_object() else {
488+
pgrx::error!("df.with_http_options(): options must be a JSON object");
489+
};
490+
491+
if let Some(key) = map.keys().next() {
492+
pgrx::error!(
493+
"df.with_http_options(): unrecognised option '{key}'. \
494+
No options are supported in this version."
495+
);
496+
}
497+
}
498+
499+
fut.to_string()
500+
}
501+
462502
/// Creates an HTTP request node.
463503
/// Makes an HTTP request to the specified URL and returns the response.
464504
///

0 commit comments

Comments
 (0)