Skip to content

Commit dcd046f

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 Hard. The normal approach (for example taken in #377 with the `df.loop`) is to keep the old function around, renamed (but with the same wrapper name). This is fine (and even avoids issues when `ALTER EXTENSION UPDATE` is not run), but `df.http` and `df.http_multipart` are functions that will likely have a bunch of `GRANT`s (and those grants are semantically meaningful to `pg_durable` beyond our ability to call the functions). If we try to capture and re-issue[^1] those grants from an extension, PG will record the grants as coming from the extension update script, and assume it doesn't need to provide them in pg_dump, so then the pg_restore won't have them, meaning logical restore and/or PG upgrades will be broken. The AI suggested the right approach was some catalog feng shui but that it would take a while to engineer. I asked on the PostgreSQL discord, and one of the PG committers (rhass) told me that this (trying to copy grants from one function to another) was something that you should never do, and to just have users reissue the grants on update. So... instead of that, we just add a 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`, but... well, yeah. 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]: This is ignoring the fact that the permissions may be different now, for example even ignoring the next issue this won't work quite right if a delegator of a grant became a superuser.
1 parent 807be96 commit dcd046f

8 files changed

Lines changed: 337 additions & 1 deletion

File tree

USER_GUIDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,24 @@ df.http(
701701
) RETURNS TEXT -- JSON response object
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 beyond the arguments passed to `df.http` and `df.http_multipart`.
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+
704722
### Response Format
705723

706724
HTTP calls return a JSON object with full response details:

docs/api-reference.md

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

368368
---
369369

370+
### df.with_http_options(fut, options)
371+
372+
HTTP-specific modifier entry point. Returns the JSON-encoded TEXT node for use in
373+
a workflow, not an HTTP response. Neither existing HTTP function changes signature.
374+
375+
| Parameter | Type | Auto-wrap | Description |
376+
|-----------|------|-----------|-------------|
377+
| `fut` | TEXT | ❌ Literal | A single `HTTP` or `HTTP_MULTIPART` node, optionally named with `\|=>` |
378+
| `options` | JSONB | ❌ Literal | SQL `NULL` or an empty object (`'{}'`) only in this version |
379+
380+
```sql
381+
df.with_http_options(df.http('https://api.github.com/', 'GET'), '{}'::jsonb)
382+
|=> 'response'
383+
```
384+
385+
No option keys are supported yet. Unknown keys, non-object JSON values (including
386+
JSON `null`), malformed nodes, SQL nodes, and compound graphs raise an error.
387+
SQL `NULL` and `{}` return the original node text byte-for-byte, preserving its
388+
config and result name. Apply the helper to each HTTP node before combining nodes.
389+
It neither resolves secrets nor grants HTTP access; activity-time permission and
390+
network checks still apply. Existing installations need `ALTER EXTENSION pg_durable
391+
UPDATE` to use this new helper, but not to keep using the original HTTP functions.
392+
393+
---
394+
370395
## Control Functions
371396

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

docs/http-security.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ superusers always return `true`; regular roles return `true` only when an
117117
explicit `GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO <role>` (or a role that
118118
inherits one) is in effect.
119119

120+
`df.with_http_options(text,jsonb)` is a node modifier, not a network operation.
121+
Like other combinators, it uses ordinary `df` schema access and default PUBLIC
122+
`EXECUTE`. Wrapping a hand-crafted HTTP node does not bypass the activity's
123+
privilege check. No option keys are supported in this version; SQL `NULL` and
124+
`{}` preserve the original node text.
125+
120126
### 3.3 Managing access
121127

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

docs/upgrade-testing.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ 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-
### 0.2.8
206+
### v0.2.7 → v0.2.8
207+
208+
#### Loop failure continuation
207209

208210
- `sql/pg_durable--0.2.7--0.2.8.sql` renames `df.loop(text, text)` to
209211
`df._loop_legacy(text, text)`, preserving its function OID and dependent
@@ -233,6 +235,12 @@ what the upgrade script handles, and any backward compatibility considerations.
233235
binary, which continues toward the higher backstop instead. Drain such
234236
long-running loops before upgrade when continuity is required.
235237

238+
#### Add `df.with_http_options()`
239+
- **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.
240+
- **Upgrade script:** [sql/pg_durable--0.2.7--0.2.8.sql](../sql/pg_durable--0.2.7--0.2.8.sql) adds this helper without replacing the existing HTTP functions. The new helper uses the same schema-access and default PUBLIC `EXECUTE` model as other combinators; it does not grant HTTP access.
241+
- **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.
242+
- **Scenario B1 considerations:** The new helper remains absent until `ALTER EXTENSION UPDATE`. The new `.so` exports `with_http_options_wrapper`; existing HTTP function signatures, C symbols, OIDs and ACLs are unchanged.
243+
236244
### v0.2.6 → v0.2.7
237245

238246
#### Transaction-aware graph admission

scripts/test-upgrade.sh

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,17 @@ test_b1_conditional_loop() {
778778
assert_sql_contains "SELECT df.loop('SELECT 1', 'SELECT false');" '"node_type":"LOOP"'
779779
}
780780

781+
test_b1_http_construction() {
782+
assert_sql_contains "SELECT df.http('https://api.github.com/');" '"node_type":"HTTP"' &&
783+
assert_sql_equals \
784+
"SELECT (df.http('https://api.github.com/', 'GET', NULL, NULL, 7)::jsonb->>'query')::jsonb->>'timeout_seconds';" \
785+
"7"
786+
}
787+
788+
test_b1_http_options_absent() {
789+
assert_sql_equals "SELECT to_regprocedure('df.with_http_options(text,jsonb)') IS NULL;" "t"
790+
}
791+
781792
test_b1_dsl_chain() {
782793
assert_sql_contains "SELECT df.sql('SELECT 1') ~> df.sql('SELECT 2');" '"node_type":"THEN"'
783794
}
@@ -909,6 +920,10 @@ else
909920
run_test "B1 [v${B1_VERSION}]: df.version()" test_b1_version
910921
run_test "B1 [v${B1_VERSION}]: df.sql() construction" test_b1_dsl_construction
911922
run_test "B1 [v${B1_VERSION}]: df.loop(body, condition)" test_b1_conditional_loop
923+
run_test "B1 [v${B1_VERSION}]: df.http() construction" test_b1_http_construction
924+
if ! version_ge "$B1_VERSION" "0.2.8"; then
925+
run_test "B1 [v${B1_VERSION}]: new HTTP options helper remains absent" test_b1_http_options_absent
926+
fi
912927
run_test "B1 [v${B1_VERSION}]: DSL chain (~>)" test_b1_dsl_chain
913928
run_test "B1 [v${B1_VERSION}]: conditional operators (?>/!>)" test_b1_conditional_operators
914929
run_test "B1 [v${B1_VERSION}]: df.start()/wait_for_completion()" test_b1_start_and_complete
@@ -1030,13 +1045,63 @@ test_b2_grant_usage_after_upgrade() {
10301045
run_sql_capture "DROP OWNED BY ${probe_role}; DROP ROLE IF EXISTS ${probe_role};" >/dev/null 2>&1 || true
10311046
}
10321047

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

10421107
# ============================================================================

sql/pg_durable--0.2.7--0.2.8.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,11 @@ CREATE FUNCTION df."loop"(
1515
) RETURNS TEXT
1616
LANGUAGE c
1717
AS 'MODULE_PATHNAME', 'loop_with_policy_wrapper';
18+
19+
-- HTTP options are additive; existing function ABIs, OIDs and ACLs stay unchanged.
20+
CREATE FUNCTION df."with_http_options"(
21+
"fut" TEXT,
22+
"options" jsonb
23+
) RETURNS TEXT
24+
LANGUAGE c
25+
AS 'MODULE_PATHNAME', 'with_http_options_wrapper';

src/dsl.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,52 @@ pub fn race(a: &str, b: &str) -> String {
487487
.to_json()
488488
}
489489

490+
/// Applies HTTP options to a single HTTP or HTTP_MULTIPART node.
491+
#[pg_extern(schema = "df")]
492+
pub fn with_http_options(fut: &str, options: Option<pgrx::JsonB>) -> String {
493+
use std::{collections::HashSet, sync::LazyLock};
494+
static ALLOWED_KEYS: LazyLock<HashSet<&'static str>> = {
495+
LazyLock::new(|| {
496+
HashSet::from_iter([
497+
// Intentionally empty for now.
498+
])
499+
})
500+
};
501+
let node = Durofut::try_from_json(fut).unwrap_or_else(|_| {
502+
pgrx::error!("df.with_http_options(): expected an HTTP or HTTP_MULTIPART node")
503+
});
504+
505+
if !matches!(node.node_type.as_str(), "HTTP" | "HTTP_MULTIPART")
506+
|| node.left_node.is_some()
507+
|| node.right_node.is_some()
508+
|| node.condition_node.is_some()
509+
|| !node.extra_nodes.is_empty()
510+
{
511+
pgrx::error!("df.with_http_options(): expected a single HTTP or HTTP_MULTIPART node");
512+
}
513+
514+
let config = node
515+
.query
516+
.as_deref()
517+
.and_then(|query| serde_json::from_str::<serde_json::Value>(query).ok());
518+
if !config.as_ref().is_some_and(serde_json::Value::is_object) {
519+
pgrx::error!("df.with_http_options(): HTTP node config must be a JSON object");
520+
}
521+
522+
if let Some(options) = options {
523+
let Some(map) = options.0.as_object() else {
524+
pgrx::error!("df.with_http_options(): options must be a JSON object");
525+
};
526+
for key in map.keys() {
527+
if !ALLOWED_KEYS.contains(key.as_str()) {
528+
pgrx::error!("df.with_http_options(): unrecognised option '{key}'.");
529+
}
530+
}
531+
}
532+
533+
fut.to_string()
534+
}
535+
490536
/// Creates an HTTP request node.
491537
/// Makes an HTTP request to the specified URL and returns the response.
492538
///

0 commit comments

Comments
 (0)