on master help sign in
Implement type-safe HTTP client with fetch/fetchWith using route declarations
commit1b41bdfba084709d5d361725d7b50aa854c2af5a authoriko <mail@iko.soy> dateFri 20 Mar 2026, 09:12 (6 months ago) parenteb96abe
8 files changed+968−2
CLAUDE.md+2−1 Cargo.lock+408 Cargo.toml+1 crates/knot-compiler/src/codegen.rs+107 crates/knot-compiler/src/effects.rs+1−1 crates/knot-compiler/src/infer.rs+189 crates/knot-runtime/Cargo.toml+1 crates/knot-runtime/src/lib.rs+259
CLAUDE.md+2−1
@@ -99,11 +99,12 @@ Key codegen patterns:
9999- IO do-blocks: detected via `is_io_do_block` (checks if any statement binds from or is a bare IO-returning builtin); compiled by `compile_io_do_as_thunk` which runs each IO action inline with `knot_io_run(db, io)` and wraps the final result in `knot_io_pure`; `compile_main` calls `knot_io_run(db, result)` on the main function's return value so IO main functions execute their effects
100100- IO runtime representation: `Value::IO(fn_ptr, env)` where fn_ptr has signature `extern "C" fn(db, env) -> *mut Value`; `knot_io_pure` wraps a value in a trivial IO (null fn_ptr, value as env); `knot_io_run` executes thunks or returns non-IO values as-is; `knot_io_bind` and `knot_io_then` compose IO actions via nested thunks with captured environments
101101- Effectful builtins (`println`, `readFile`, `now`, etc.) compile to `_io` wrapper functions (e.g. `knot_println_io`) that capture arguments and return `Value::IO` thunks instead of executing immediately
102+- HTTP client (`fetch`/`fetchWith`): compiler-special-cased forms that reuse route declarations for type safety; `compile_fetch` extracts the constructor's route entry metadata (method, path pattern, body/query field descriptors, response schema) at compile time and passes them as string constants alongside the compiled record payload to `knot_http_fetch_io`, which returns an IO thunk; the thunk builds the URL (substituting path params), serializes body fields to JSON, sets headers, executes the HTTP request via `ureq`, parses the JSON response, and wraps it in a `Result` ADT (`Ok {value: parsed}` or `Err {error: {status, message}}`); type inference (`try_infer_fetch`) fully handles these forms — it skips the constructor's `respond` field when type-checking the record argument and resolves the response type from `fetch_response_types` (populated from route declarations during `pre_register`)
102103- `groupBy` in do-blocks: compiles as a two-phase loop — phase 1 collects qualifying rows into a temp relation (inside existing loops), closes those loops, then calls `knot_relation_group_by(db, temp, schema, key_cols)` which uses SQLite for grouping: inserts row indices + key column values into a temp table, executes `ORDER BY` on key columns, and groups consecutive rows with matching keys in O(n); phase 2 opens a new loop over the groups and rebinds the primary bind variable to each group (a sub-relation); field access on groups delegates to the first element via `knot_record_field`'s `Value::Relation` handling; codegen extracts key column names from the record key expression and schema from `source_schemas`
103104
104105## Supported Language Features
105106
106−Currently compiled: source declarations, type aliases, data declarations, functions, literals, records, field access, record updates, relation literals, nested relations (record fields holding `[T]` stored as JSON in SQLite, queryable via `m <- t.field` bind), binary/unary operations, if/else, do blocks (bind/where/yield/let/groupBy — pure comprehension do blocks are desugared to `__bind`/`__yield`/`__empty` calls that dispatch through Monad/Applicative/Alternative trait impls — works with any monad, not just `[]` (e.g., `Maybe`); monad type resolved at compile time via HKT unification; mixed/sequential do blocks use direct loop codegen; set-value do blocks preserved for SQL optimization; `groupBy {key}` collects rows, groups by key, and rebinds the variable to each group sub-relation — field access on a group returns the first element's field, aggregates like `count` operate on the group), set expressions, lambdas, closures, function application, case expressions (with exhaustiveness checking for ADTs), constructors, atomic transactions, migrations (schema tracking + `migrate` blocks), schema lockfile (`<name>.schema.lock`), views (`*view = do { ... yield {...} }` with bidirectional read/write, constant column filtering, auto-fill on write), derived relations (`&name = expr` — read-only computed relations, compiled as 0-param functions recomputed on each access; recursive derived relations with self-references use Datalog-style fixpoint iteration via `knot_relation_fixpoint` runtime function, converging when the relation stabilizes), traits and impls (single-dispatch on runtime type tags for primitives and ADTs, default methods, deriving from defaults, supertrait enforcement, associated types with type-level pattern matching), trait bounds (`Display a => [a] -> [Text]` syntax — explicit constraints on function type signatures validated at call sites; trait method calls automatically carry constraints; multiple bounds via `Trait1 a => Trait2 a => Type`; compile-time error when a concrete type lacks a required impl), type inference (Hindley-Milner with row polymorphism for both records and variants, let-generalization, ADT/trait/view-aware, higher-kinded type polymorphism), higher-kinded types (trait params with kind annotations like `(f : Type -> Type)`, type-level application `f a` in method signatures, `[]` and ADT names as bare type constructors in impl args, `TyCon`/`App` internal representation with normalization-based unification), IO monad (`IO {effects} a` type for pure effect descriptions — effectful builtins like `println`, `readFile`, `now` return IO values instead of performing effects immediately; IO do-blocks sequence actions via bind; effects tracked in the type: `IO {console} {}`, `IO {fs} Text`, `IO {clock} Int`; `knot_io_run` executes IO thunks at the top level in `main`; relation comprehensions are unaffected), temporal queries (`*rel : [T] with history` enables history tracking, `*rel @(timestamp)` queries past state, `now` returns `IO {clock} Int`), pattern matching in do-bind (`Circle c <- *shapes` filters relation to matching constructors and destructures; `InProgress ip <- t.status` matches a single value and skips if no match), built-ins (println, print, show, union, count, now, filter, match, map, fold, single, diff, inter, sum, avg, toUpper, toLower, take, drop, length, trim, contains, reverse, chars, id, not, toJson, parseJson, readFile, writeFile, appendFile, fileExists, removeFile, listDir, readLine, randomInt, randomFloat — IO-returning builtins produce `IO {effect} a` values), effect inference (per-declaration effect tracking for reads/writes/console/clock/network/fs/random, IO-in-atomic validation, explicit annotation checking), subset constraints (`*orders.customer <= *people.name` for referential integrity, `*users <= *users.email` for uniqueness — validated at runtime before each write commits), routes (`route Name where` with HTTP method/path/body/query/response declarations, path prefix nesting, `route Name = A | B` composition, desugared to ADTs with `respond` callback for per-branch response type safety, `listen port handler` starts `tiny_http` server with route table matching, JSON request/response serialization).
107+Currently compiled: source declarations, type aliases, data declarations, functions, literals, records, field access, record updates, relation literals, nested relations (record fields holding `[T]` stored as JSON in SQLite, queryable via `m <- t.field` bind), binary/unary operations, if/else, do blocks (bind/where/yield/let/groupBy — pure comprehension do blocks are desugared to `__bind`/`__yield`/`__empty` calls that dispatch through Monad/Applicative/Alternative trait impls — works with any monad, not just `[]` (e.g., `Maybe`); monad type resolved at compile time via HKT unification; mixed/sequential do blocks use direct loop codegen; set-value do blocks preserved for SQL optimization; `groupBy {key}` collects rows, groups by key, and rebinds the variable to each group sub-relation — field access on a group returns the first element's field, aggregates like `count` operate on the group), set expressions, lambdas, closures, function application, case expressions (with exhaustiveness checking for ADTs), constructors, atomic transactions, migrations (schema tracking + `migrate` blocks), schema lockfile (`<name>.schema.lock`), views (`*view = do { ... yield {...} }` with bidirectional read/write, constant column filtering, auto-fill on write), derived relations (`&name = expr` — read-only computed relations, compiled as 0-param functions recomputed on each access; recursive derived relations with self-references use Datalog-style fixpoint iteration via `knot_relation_fixpoint` runtime function, converging when the relation stabilizes), traits and impls (single-dispatch on runtime type tags for primitives and ADTs, default methods, deriving from defaults, supertrait enforcement, associated types with type-level pattern matching), trait bounds (`Display a => [a] -> [Text]` syntax — explicit constraints on function type signatures validated at call sites; trait method calls automatically carry constraints; multiple bounds via `Trait1 a => Trait2 a => Type`; compile-time error when a concrete type lacks a required impl), type inference (Hindley-Milner with row polymorphism for both records and variants, let-generalization, ADT/trait/view-aware, higher-kinded type polymorphism), higher-kinded types (trait params with kind annotations like `(f : Type -> Type)`, type-level application `f a` in method signatures, `[]` and ADT names as bare type constructors in impl args, `TyCon`/`App` internal representation with normalization-based unification), IO monad (`IO {effects} a` type for pure effect descriptions — effectful builtins like `println`, `readFile`, `now` return IO values instead of performing effects immediately; IO do-blocks sequence actions via bind; effects tracked in the type: `IO {console} {}`, `IO {fs} Text`, `IO {clock} Int`; `knot_io_run` executes IO thunks at the top level in `main`; relation comprehensions are unaffected), temporal queries (`*rel : [T] with history` enables history tracking, `*rel @(timestamp)` queries past state, `now` returns `IO {clock} Int`), pattern matching in do-bind (`Circle c <- *shapes` filters relation to matching constructors and destructures; `InProgress ip <- t.status` matches a single value and skips if no match), built-ins (println, print, show, union, count, now, filter, match, map, fold, single, diff, inter, sum, avg, toUpper, toLower, take, drop, length, trim, contains, reverse, chars, id, not, toJson, parseJson, readFile, writeFile, appendFile, fileExists, removeFile, listDir, readLine, randomInt, randomFloat — IO-returning builtins produce `IO {effect} a` values), effect inference (per-declaration effect tracking for reads/writes/console/clock/network/fs/random, IO-in-atomic validation, explicit annotation checking), subset constraints (`*orders.customer <= *people.name` for referential integrity, `*users <= *users.email` for uniqueness — validated at runtime before each write commits), routes (`route Name where` with HTTP method/path/body/query/response declarations, path prefix nesting, `route Name = A | B` composition, desugared to ADTs with `respond` callback for per-branch response type safety, `listen port handler` starts `tiny_http` server with route table matching, JSON request/response serialization, `fetch url (Ctor {fields})` type-safe HTTP client reusing route declarations — response type inferred from route's `response:` annotation, returns `IO {network} (Result {status: Int, message: Text} ResponseType)`, `fetchWith url {headers: [...]} (Ctor {fields})` variant with custom headers, uses `ureq` for HTTP requests).
107108
108109## Version Control
109110
Cargo.lock+408
@@ -2,6 +2,12 @@
22# It is not intended for manual editing.
33version = 4
44
5+[[package]]
6+name = "adler2"
7+version = "2.0.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10+
511[[package]]
612name = "aead"
713version = "0.5.2"
@@ -52,6 +58,12 @@ version = "1.5.0"
5258source = "registry+https://github.com/rust-lang/crates.io-index"
5359checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
5460
61+[[package]]
62+name = "base64"
63+version = "0.22.1"
64+source = "registry+https://github.com/rust-lang/crates.io-index"
65+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
66+
5567[[package]]
5668name = "bitflags"
5769version = "1.3.2"
@@ -435,6 +447,17 @@ dependencies = [
435447 "crypto-common",
436448]
437449
450+[[package]]
451+name = "displaydoc"
452+version = "0.2.5"
453+source = "registry+https://github.com/rust-lang/crates.io-index"
454+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
455+dependencies = [
456+ "proc-macro2",
457+ "quote",
458+ "syn",
459+]
460+
438461[[package]]
439462name = "ed25519"
440463version = "2.2.3"
@@ -502,6 +525,16 @@ version = "0.1.9"
502525source = "registry+https://github.com/rust-lang/crates.io-index"
503526checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
504527
528+[[package]]
529+name = "flate2"
530+version = "1.1.9"
531+source = "registry+https://github.com/rust-lang/crates.io-index"
532+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
533+dependencies = [
534+ "crc32fast",
535+ "miniz_oxide",
536+]
537+
505538[[package]]
506539name = "fluent-uri"
507540version = "0.1.4"
@@ -517,6 +550,15 @@ version = "0.1.5"
517550source = "registry+https://github.com/rust-lang/crates.io-index"
518551checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
519552
553+[[package]]
554+name = "form_urlencoded"
555+version = "1.2.2"
556+source = "registry+https://github.com/rust-lang/crates.io-index"
557+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
558+dependencies = [
559+ "percent-encoding",
560+]
561+
520562[[package]]
521563name = "generic-array"
522564version = "0.14.7"
@@ -605,12 +647,114 @@ version = "1.0.3"
605647source = "registry+https://github.com/rust-lang/crates.io-index"
606648checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
607649
650+[[package]]
651+name = "icu_collections"
652+version = "2.1.1"
653+source = "registry+https://github.com/rust-lang/crates.io-index"
654+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
655+dependencies = [
656+ "displaydoc",
657+ "potential_utf",
658+ "yoke",
659+ "zerofrom",
660+ "zerovec",
661+]
662+
663+[[package]]
664+name = "icu_locale_core"
665+version = "2.1.1"
666+source = "registry+https://github.com/rust-lang/crates.io-index"
667+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
668+dependencies = [
669+ "displaydoc",
670+ "litemap",
671+ "tinystr",
672+ "writeable",
673+ "zerovec",
674+]
675+
676+[[package]]
677+name = "icu_normalizer"
678+version = "2.1.1"
679+source = "registry+https://github.com/rust-lang/crates.io-index"
680+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
681+dependencies = [
682+ "icu_collections",
683+ "icu_normalizer_data",
684+ "icu_properties",
685+ "icu_provider",
686+ "smallvec",
687+ "zerovec",
688+]
689+
690+[[package]]
691+name = "icu_normalizer_data"
692+version = "2.1.1"
693+source = "registry+https://github.com/rust-lang/crates.io-index"
694+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
695+
696+[[package]]
697+name = "icu_properties"
698+version = "2.1.2"
699+source = "registry+https://github.com/rust-lang/crates.io-index"
700+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
701+dependencies = [
702+ "icu_collections",
703+ "icu_locale_core",
704+ "icu_properties_data",
705+ "icu_provider",
706+ "zerotrie",
707+ "zerovec",
708+]
709+
710+[[package]]
711+name = "icu_properties_data"
712+version = "2.1.2"
713+source = "registry+https://github.com/rust-lang/crates.io-index"
714+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
715+
716+[[package]]
717+name = "icu_provider"
718+version = "2.1.1"
719+source = "registry+https://github.com/rust-lang/crates.io-index"
720+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
721+dependencies = [
722+ "displaydoc",
723+ "icu_locale_core",
724+ "writeable",
725+ "yoke",
726+ "zerofrom",
727+ "zerotrie",
728+ "zerovec",
729+]
730+
608731[[package]]
609732name = "ident_case"
610733version = "1.0.1"
611734source = "registry+https://github.com/rust-lang/crates.io-index"
612735checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
613736
737+[[package]]
738+name = "idna"
739+version = "1.1.0"
740+source = "registry+https://github.com/rust-lang/crates.io-index"
741+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
742+dependencies = [
743+ "idna_adapter",
744+ "smallvec",
745+ "utf8_iter",
746+]
747+
748+[[package]]
749+name = "idna_adapter"
750+version = "1.2.1"
751+source = "registry+https://github.com/rust-lang/crates.io-index"
752+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
753+dependencies = [
754+ "icu_normalizer",
755+ "icu_properties",
756+]
757+
614758[[package]]
615759name = "indexmap"
616760version = "2.13.0"
@@ -714,6 +858,7 @@ dependencies = [
714858 "ratatui",
715859 "rusqlite",
716860 "tiny_http",
861+ "ureq",
717862 "x25519-dalek",
718863]
719864
@@ -740,6 +885,12 @@ version = "0.4.15"
740885source = "registry+https://github.com/rust-lang/crates.io-index"
741886checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
742887
888+[[package]]
889+name = "litemap"
890+version = "0.8.1"
891+source = "registry+https://github.com/rust-lang/crates.io-index"
892+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
893+
743894[[package]]
744895name = "lock_api"
745896version = "0.4.14"
@@ -796,6 +947,16 @@ version = "2.8.0"
796947source = "registry+https://github.com/rust-lang/crates.io-index"
797948checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
798949
950+[[package]]
951+name = "miniz_oxide"
952+version = "0.8.9"
953+source = "registry+https://github.com/rust-lang/crates.io-index"
954+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
955+dependencies = [
956+ "adler2",
957+ "simd-adler32",
958+]
959+
799960[[package]]
800961name = "mio"
801962version = "1.1.1"
@@ -848,6 +1009,12 @@ dependencies = [
8481009 "memchr",
8491010]
8501011
1012+[[package]]
1013+name = "once_cell"
1014+version = "1.21.4"
1015+source = "registry+https://github.com/rust-lang/crates.io-index"
1016+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1017+
8511018[[package]]
8521019name = "opaque-debug"
8531020version = "0.3.1"
@@ -883,6 +1050,12 @@ version = "1.0.15"
8831050source = "registry+https://github.com/rust-lang/crates.io-index"
8841051checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
8851052
1053+[[package]]
1054+name = "percent-encoding"
1055+version = "2.3.2"
1056+source = "registry+https://github.com/rust-lang/crates.io-index"
1057+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1058+
8861059[[package]]
8871060name = "pkg-config"
8881061version = "0.3.32"
@@ -900,6 +1073,15 @@ dependencies = [
9001073 "universal-hash",
9011074]
9021075
1076+[[package]]
1077+name = "potential_utf"
1078+version = "0.1.4"
1079+source = "registry+https://github.com/rust-lang/crates.io-index"
1080+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
1081+dependencies = [
1082+ "zerovec",
1083+]
1084+
9031085[[package]]
9041086name = "proc-macro2"
9051087version = "1.0.106"
@@ -977,6 +1159,20 @@ dependencies = [
9771159 "smallvec",
9781160]
9791161
1162+[[package]]
1163+name = "ring"
1164+version = "0.17.14"
1165+source = "registry+https://github.com/rust-lang/crates.io-index"
1166+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1167+dependencies = [
1168+ "cc",
1169+ "cfg-if",
1170+ "getrandom 0.2.17",
1171+ "libc",
1172+ "untrusted",
1173+ "windows-sys 0.52.0",
1174+]
1175+
9801176[[package]]
9811177name = "rusqlite"
9821178version = "0.34.0"
@@ -1019,6 +1215,41 @@ dependencies = [
10191215 "windows-sys 0.59.0",
10201216]
10211217
1218+[[package]]
1219+name = "rustls"
1220+version = "0.23.37"
1221+source = "registry+https://github.com/rust-lang/crates.io-index"
1222+checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
1223+dependencies = [
1224+ "log",
1225+ "once_cell",
1226+ "ring",
1227+ "rustls-pki-types",
1228+ "rustls-webpki",
1229+ "subtle",
1230+ "zeroize",
1231+]
1232+
1233+[[package]]
1234+name = "rustls-pki-types"
1235+version = "1.14.0"
1236+source = "registry+https://github.com/rust-lang/crates.io-index"
1237+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
1238+dependencies = [
1239+ "zeroize",
1240+]
1241+
1242+[[package]]
1243+name = "rustls-webpki"
1244+version = "0.103.9"
1245+source = "registry+https://github.com/rust-lang/crates.io-index"
1246+checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
1247+dependencies = [
1248+ "ring",
1249+ "rustls-pki-types",
1250+ "untrusted",
1251+]
1252+
10221253[[package]]
10231254name = "rustversion"
10241255version = "1.0.22"
@@ -1151,6 +1382,12 @@ version = "2.2.0"
11511382source = "registry+https://github.com/rust-lang/crates.io-index"
11521383checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
11531384
1385+[[package]]
1386+name = "simd-adler32"
1387+version = "0.3.8"
1388+source = "registry+https://github.com/rust-lang/crates.io-index"
1389+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
1390+
11541391[[package]]
11551392name = "smallvec"
11561393version = "1.15.1"
@@ -1214,6 +1451,17 @@ dependencies = [
12141451 "unicode-ident",
12151452]
12161453
1454+[[package]]
1455+name = "synstructure"
1456+version = "0.13.2"
1457+source = "registry+https://github.com/rust-lang/crates.io-index"
1458+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1459+dependencies = [
1460+ "proc-macro2",
1461+ "quote",
1462+ "syn",
1463+]
1464+
12171465[[package]]
12181466name = "target-lexicon"
12191467version = "0.13.5"
@@ -1232,6 +1480,16 @@ dependencies = [
12321480 "log",
12331481]
12341482
1483+[[package]]
1484+name = "tinystr"
1485+version = "0.8.2"
1486+source = "registry+https://github.com/rust-lang/crates.io-index"
1487+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
1488+dependencies = [
1489+ "displaydoc",
1490+ "zerovec",
1491+]
1492+
12351493[[package]]
12361494name = "typenum"
12371495version = "1.19.0"
@@ -1283,6 +1541,46 @@ dependencies = [
12831541 "subtle",
12841542]
12851543
1544+[[package]]
1545+name = "untrusted"
1546+version = "0.9.0"
1547+source = "registry+https://github.com/rust-lang/crates.io-index"
1548+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1549+
1550+[[package]]
1551+name = "ureq"
1552+version = "2.12.1"
1553+source = "registry+https://github.com/rust-lang/crates.io-index"
1554+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
1555+dependencies = [
1556+ "base64",
1557+ "flate2",
1558+ "log",
1559+ "once_cell",
1560+ "rustls",
1561+ "rustls-pki-types",
1562+ "url",
1563+ "webpki-roots 0.26.11",
1564+]
1565+
1566+[[package]]
1567+name = "url"
1568+version = "2.5.8"
1569+source = "registry+https://github.com/rust-lang/crates.io-index"
1570+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1571+dependencies = [
1572+ "form_urlencoded",
1573+ "idna",
1574+ "percent-encoding",
1575+ "serde",
1576+]
1577+
1578+[[package]]
1579+name = "utf8_iter"
1580+version = "1.0.4"
1581+source = "registry+https://github.com/rust-lang/crates.io-index"
1582+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1583+
12861584[[package]]
12871585name = "vcpkg"
12881586version = "0.2.15"
@@ -1310,6 +1608,24 @@ dependencies = [
13101608 "wit-bindgen",
13111609]
13121610
1611+[[package]]
1612+name = "webpki-roots"
1613+version = "0.26.11"
1614+source = "registry+https://github.com/rust-lang/crates.io-index"
1615+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
1616+dependencies = [
1617+ "webpki-roots 1.0.6",
1618+]
1619+
1620+[[package]]
1621+name = "webpki-roots"
1622+version = "1.0.6"
1623+source = "registry+https://github.com/rust-lang/crates.io-index"
1624+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
1625+dependencies = [
1626+ "rustls-pki-types",
1627+]
1628+
13131629[[package]]
13141630name = "winapi"
13151631version = "0.3.9"
@@ -1338,6 +1654,15 @@ version = "0.2.1"
13381654source = "registry+https://github.com/rust-lang/crates.io-index"
13391655checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
13401656
1657+[[package]]
1658+name = "windows-sys"
1659+version = "0.52.0"
1660+source = "registry+https://github.com/rust-lang/crates.io-index"
1661+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1662+dependencies = [
1663+ "windows-targets",
1664+]
1665+
13411666[[package]]
13421667name = "windows-sys"
13431668version = "0.59.0"
@@ -1426,6 +1751,12 @@ version = "0.51.0"
14261751source = "registry+https://github.com/rust-lang/crates.io-index"
14271752checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
14281753
1754+[[package]]
1755+name = "writeable"
1756+version = "0.6.2"
1757+source = "registry+https://github.com/rust-lang/crates.io-index"
1758+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
1759+
14291760[[package]]
14301761name = "x25519-dalek"
14311762version = "2.0.1"
@@ -1444,6 +1775,50 @@ version = "1.0.1"
14441775source = "registry+https://github.com/rust-lang/crates.io-index"
14451776checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
14461777
1778+[[package]]
1779+name = "yoke"
1780+version = "0.8.1"
1781+source = "registry+https://github.com/rust-lang/crates.io-index"
1782+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
1783+dependencies = [
1784+ "stable_deref_trait",
1785+ "yoke-derive",
1786+ "zerofrom",
1787+]
1788+
1789+[[package]]
1790+name = "yoke-derive"
1791+version = "0.8.1"
1792+source = "registry+https://github.com/rust-lang/crates.io-index"
1793+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
1794+dependencies = [
1795+ "proc-macro2",
1796+ "quote",
1797+ "syn",
1798+ "synstructure",
1799+]
1800+
1801+[[package]]
1802+name = "zerofrom"
1803+version = "0.1.6"
1804+source = "registry+https://github.com/rust-lang/crates.io-index"
1805+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
1806+dependencies = [
1807+ "zerofrom-derive",
1808+]
1809+
1810+[[package]]
1811+name = "zerofrom-derive"
1812+version = "0.1.6"
1813+source = "registry+https://github.com/rust-lang/crates.io-index"
1814+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
1815+dependencies = [
1816+ "proc-macro2",
1817+ "quote",
1818+ "syn",
1819+ "synstructure",
1820+]
1821+
14471822[[package]]
14481823name = "zeroize"
14491824version = "1.8.2"
@@ -1464,6 +1839,39 @@ dependencies = [
14641839 "syn",
14651840]
14661841
1842+[[package]]
1843+name = "zerotrie"
1844+version = "0.2.3"
1845+source = "registry+https://github.com/rust-lang/crates.io-index"
1846+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
1847+dependencies = [
1848+ "displaydoc",
1849+ "yoke",
1850+ "zerofrom",
1851+]
1852+
1853+[[package]]
1854+name = "zerovec"
1855+version = "0.11.5"
1856+source = "registry+https://github.com/rust-lang/crates.io-index"
1857+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
1858+dependencies = [
1859+ "yoke",
1860+ "zerofrom",
1861+ "zerovec-derive",
1862+]
1863+
1864+[[package]]
1865+name = "zerovec-derive"
1866+version = "0.11.2"
1867+source = "registry+https://github.com/rust-lang/crates.io-index"
1868+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
1869+dependencies = [
1870+ "proc-macro2",
1871+ "quote",
1872+ "syn",
1873+]
1874+
14671875[[package]]
14681876name = "zmij"
14691877version = "1.0.21"
Cargo.toml+1
@@ -18,6 +18,7 @@ cranelift-native = "0.116"
1818target-lexicon = "0.13"
1919rusqlite = { version = "0.34", features = ["bundled", "collation"] }
2020tiny_http = "0.12"
21+ureq = "2"
2122num-bigint = "0.4"
2223num-traits = "0.2"
2324num-integer = "0.1"
crates/knot-compiler/src/codegen.rs+107
@@ -571,6 +571,15 @@ impl Codegen {
571571 );
572572 self.declare_rt("knot_http_listen", &[p, p, p, p], &[p]);
573573
574+ // HTTP client (fetch)
575+ // (base_url, method_ptr, method_len, path_ptr, path_len, payload,
576+ // body_ptr, body_len, query_ptr, query_len, resp_ptr, resp_len, headers)
577+ self.declare_rt(
578+ "knot_http_fetch_io",
579+ &[p, p, p, p, p, p, p, p, p, p, p, p, p],
580+ &[p],
581+ );
582+
574583 // OpenAPI / api command
575584 self.declare_rt("knot_api_register", &[p, p, p], &[]);
576585 self.declare_rt("knot_api_handle", &[types::I32, p], &[types::I32]);
@@ -3545,6 +3554,15 @@ impl Codegen {
35453554 }
35463555 }
35473556
3557+ // Special case: fetch/fetchWith
3558+ if let ast::ExprKind::Var(name) = &func_expr.node {
3559+ if (name == "fetch" && args.len() == 2)
3560+ || (name == "fetchWith" && args.len() == 3)
3561+ {
3562+ return self.compile_fetch(builder, &args, name == "fetchWith", env, db);
3563+ }
3564+ }
3565+
35483566 let compiled_args: Vec<Value> = args
35493567 .iter()
35503568 .map(|a| self.compile_expr(builder, a, env, db))
@@ -3765,6 +3783,92 @@ impl Codegen {
37653783 }
37663784 }
37673785
3786+ // ── HTTP fetch compilation ────────────────────────────────────
3787+
3788+ /// Compile `fetch url (Ctor {..})` or `fetchWith url opts (Ctor {..})`.
3789+ fn compile_fetch(
3790+ &mut self,
3791+ builder: &mut FunctionBuilder,
3792+ args: &[&ast::Expr],
3793+ with_opts: bool,
3794+ env: &mut Env,
3795+ db: Value,
3796+ ) -> Value {
3797+ let base_url = self.compile_expr(builder, args[0], env, db);
3798+
3799+ let (headers, ctor_expr) = if with_opts {
3800+ // fetchWith url opts (Ctor {..})
3801+ let opts = self.compile_expr(builder, args[1], env, db);
3802+ let (h_ptr, h_len) = self.string_ptr(builder, "headers");
3803+ let headers =
3804+ self.call_rt(builder, "knot_record_field", &[opts, h_ptr, h_len]);
3805+ (headers, args[2])
3806+ } else {
3807+ // fetch url (Ctor {..})
3808+ let null = builder.ins().iconst(self.ptr_type, 0);
3809+ (null, args[1])
3810+ };
3811+
3812+ // Extract constructor name and record argument from the AST
3813+ let (ctor_name, record_expr) = match &ctor_expr.node {
3814+ ast::ExprKind::App { func, arg } => {
3815+ if let ast::ExprKind::Constructor(name) = &func.node {
3816+ (name.clone(), Some(arg.as_ref()))
3817+ } else {
3818+ panic!("fetch: expected constructor application as last argument");
3819+ }
3820+ }
3821+ ast::ExprKind::Constructor(name) => (name.clone(), None),
3822+ _ => panic!("fetch: expected constructor application as last argument"),
3823+ };
3824+
3825+ // Compile just the record payload (skip the Constructor wrapper)
3826+ let payload = match record_expr {
3827+ Some(expr) => self.compile_expr(builder, expr, env, db),
3828+ None => self.call_rt(builder, "knot_value_unit", &[]),
3829+ };
3830+
3831+ // Look up route entry for this constructor
3832+ let entry = self
3833+ .route_entries
3834+ .values()
3835+ .flat_map(|entries| entries.iter())
3836+ .find(|e| e.constructor == ctor_name)
3837+ .cloned()
3838+ .unwrap_or_else(|| {
3839+ panic!("fetch: no route entry found for constructor '{}'", ctor_name)
3840+ });
3841+
3842+ let method_str = match entry.method {
3843+ ast::HttpMethod::Get => "GET",
3844+ ast::HttpMethod::Post => "POST",
3845+ ast::HttpMethod::Put => "PUT",
3846+ ast::HttpMethod::Delete => "DELETE",
3847+ ast::HttpMethod::Patch => "PATCH",
3848+ };
3849+ let path_pattern = path_segments_to_pattern(&entry.path);
3850+ let body_desc = fields_to_descriptor(&entry.body_fields);
3851+ let query_desc = fields_to_descriptor(&entry.query_params);
3852+ let resp_desc =
3853+ response_type_descriptor(&entry.response_ty, &self.type_aliases);
3854+
3855+ let (method_ptr, method_len) = self.string_ptr(builder, method_str);
3856+ let (path_ptr, path_len) = self.string_ptr(builder, &path_pattern);
3857+ let (body_ptr, body_len) = self.string_ptr(builder, &body_desc);
3858+ let (query_ptr, query_len) = self.string_ptr(builder, &query_desc);
3859+ let (resp_ptr, resp_len) = self.string_ptr(builder, &resp_desc);
3860+
3861+ self.call_rt(
3862+ builder,
3863+ "knot_http_fetch_io",
3864+ &[
3865+ base_url, method_ptr, method_len, path_ptr, path_len, payload,
3866+ body_ptr, body_len, query_ptr, query_len, resp_ptr, resp_len,
3867+ headers,
3868+ ],
3869+ )
3870+ }
3871+
37683872 // ── Case expression compilation ───────────────────────────────
37693873
37703874 fn compile_case(
@@ -4082,6 +4186,7 @@ impl Codegen {
40824186 "println" | "putLine" | "print" | "readLine" | "readFile"
40834187 | "writeFile" | "appendFile" | "fileExists" | "removeFile"
40844188 | "listDir" | "now" | "randomInt" | "randomFloat"
4189+ | "fetch" | "fetchWith"
40854190 ),
40864191 _ => false,
40874192 }
@@ -7236,6 +7341,8 @@ fn is_builtin_name(name: &str) -> bool {
72367341 | "__yield"
72377342 | "__empty"
72387343 | "listen"
7344+ | "fetch"
7345+ | "fetchWith"
72397346 | "single"
72407347 | "toUpper"
72417348 | "toLower"
crates/knot-compiler/src/effects.rs+1−1
@@ -201,7 +201,7 @@ impl EffectChecker {
201201
202202 let mut network_effect = EffectSet::empty();
203203 network_effect.network = true;
204− insert_many(&["listen"], network_effect);
204+ insert_many(&["listen", "fetch", "fetchWith"], network_effect);
205205
206206 let mut fs_effect = EffectSet::empty();
207207 fs_effect.fs = true;
crates/knot-compiler/src/infer.rs+189
@@ -209,6 +209,9 @@ struct Infer {
209209
210210 /// Spans of local variable bindings and their types (for LSP hover).
211211 binding_types: Vec<(Span, Ty)>,
212+
213+ /// Route constructor → response type mapping (for `fetch` return type resolution).
214+ fetch_response_types: HashMap<String, ast::Type>,
212215}
213216
214217// ── Core operations ───────────────────────────────────────────────
@@ -234,6 +237,7 @@ impl Infer {
234237 deferred_constraints: Vec::new(),
235238 binding_types: Vec::new(),
236239 trait_params: HashMap::new(),
240+ fetch_response_types: HashMap::new(),
237241 }
238242 }
239243
@@ -1484,6 +1488,12 @@ impl Infer {
14841488 }
14851489
14861490 ast::ExprKind::App { func, arg } => {
1491+ // Special case: fully handle `fetch url (Ctor {..})` to
1492+ // skip the `respond` field and resolve the response type.
1493+ if let Some(ty) = self.try_infer_fetch(expr) {
1494+ return ty;
1495+ }
1496+
14871497 let func_ty = self.infer_expr(func);
14881498 let arg_ty = self.infer_expr(arg);
14891499 let result_ty = self.fresh();
@@ -1594,6 +1604,85 @@ impl Infer {
15941604 }
15951605 }
15961606
1607+ /// Try to infer a `fetch` call. Returns `Some(ty)` if the expression
1608+ /// is `fetch url (Ctor {..})` or `fetch url opts (Ctor {..})`.
1609+ /// This skips the constructor's `respond` field and resolves the
1610+ /// response type from route metadata.
1611+ fn try_infer_fetch(&mut self, expr: &ast::Expr) -> Option<Ty> {
1612+ let ctor_name = fetch_ctor_name(expr)?;
1613+
1614+ // Collect all App arguments and the root function
1615+ let (func_expr, args) = uncurry_fetch(expr);
1616+
1617+ // Root must be Var("fetch") or Var("fetchWith")
1618+ let is_fetch_with = match &func_expr.node {
1619+ ast::ExprKind::Var(name) if name == "fetch" => false,
1620+ ast::ExprKind::Var(name) if name == "fetchWith" => true,
1621+ _ => return None,
1622+ };
1623+
1624+ // Validate arg count: fetch needs 2, fetchWith needs 3
1625+ if (!is_fetch_with && args.len() != 2) || (is_fetch_with && args.len() != 3) {
1626+ return None;
1627+ }
1628+
1629+ // Infer URL argument (should be Text)
1630+ let url_ty = self.infer_expr(args[0]);
1631+ self.unify(&url_ty, &Ty::Text, args[0].span);
1632+
1633+ // If fetchWith, infer the options record
1634+ if is_fetch_with {
1635+ let _opts_ty = self.infer_expr(args[1]);
1636+ }
1637+
1638+ // Infer the constructor's record payload WITHOUT the `respond` field.
1639+ let ctor_arg = args.last().unwrap();
1640+ let record_arg = match &ctor_arg.node {
1641+ ast::ExprKind::App { arg, .. } => arg.as_ref(),
1642+ _ => ctor_arg,
1643+ };
1644+ let record_ty = self.infer_expr(record_arg);
1645+
1646+ // Build the expected request fields from the route entry (exclude `respond`)
1647+ if let Some(info) = self.constructors.get(ctor_name).cloned() {
1648+ self.annotation_vars.clear();
1649+ for p in &info.data_params {
1650+ let v = self.fresh_var();
1651+ self.annotation_vars.insert(p.clone(), v);
1652+ }
1653+ let field_tys: BTreeMap<String, Ty> = info
1654+ .fields
1655+ .iter()
1656+ .filter(|(name, _)| name != "respond")
1657+ .map(|(name, ty)| (name.clone(), self.ast_type_to_ty(ty)))
1658+ .collect();
1659+ let expected_record = Ty::Record(field_tys, None);
1660+ self.unify(&record_ty, &expected_record, ctor_arg.span);
1661+ }
1662+
1663+ // Build the return type: IO {network} (Result {status, message} ResponseTy)
1664+ let resp_ty = self
1665+ .fetch_response_types
1666+ .get(ctor_name)
1667+ .cloned();
1668+ let body_ty = match resp_ty {
1669+ Some(ref ty) => self.ast_type_to_ty(ty),
1670+ None => Ty::Text,
1671+ };
1672+ let err_ty = Ty::Record(
1673+ BTreeMap::from([
1674+ ("message".into(), Ty::Text),
1675+ ("status".into(), Ty::Int),
1676+ ]),
1677+ None,
1678+ );
1679+ let result_adt = Ty::Con("Result".into(), vec![err_ty, body_ty]);
1680+ Some(Ty::IO(
1681+ BTreeSet::from([IoEffect::Network]),
1682+ Box::new(result_adt),
1683+ ))
1684+ }
1685+
15971686 fn infer_binop(
15981687 &mut self,
15991688 op: ast::BinOp,
@@ -2206,6 +2295,14 @@ impl Infer {
22062295 } => {
22072296 self.register_trait_methods(trait_name, params, items);
22082297 }
2298+ ast::DeclKind::Route { entries, .. } => {
2299+ for entry in entries {
2300+ if let Some(ref resp_ty) = entry.response_ty {
2301+ self.fetch_response_types
2302+ .insert(entry.constructor.clone(), resp_ty.clone());
2303+ }
2304+ }
2305+ }
22092306 _ => {}
22102307 }
22112308 }
@@ -2389,6 +2486,54 @@ impl Infer {
23892486 ),
23902487 );
23912488
2489+ // fetch : ∀a b. Text -> a -> IO {network} (Result {status: Int, message: Text} b)
2490+ // (also accepts 3-arg form with options record in the middle)
2491+ // The response type `b` is resolved via special inference when the
2492+ // second/third arg is a route constructor with a known response type.
2493+ {
2494+ let a = self.fresh_var();
2495+ let b = self.fresh_var();
2496+ let err_ty = Ty::Record(
2497+ BTreeMap::from([
2498+ ("message".into(), Ty::Text),
2499+ ("status".into(), Ty::Int),
2500+ ]),
2501+ None,
2502+ );
2503+ let result_ty = Ty::Con("Result".into(), vec![err_ty.clone(), Ty::Var(b)]);
2504+ let io_ty = Ty::IO(BTreeSet::from([IoEffect::Network]), Box::new(result_ty));
2505+ self.bind_top(
2506+ "fetch",
2507+ Scheme::poly(
2508+ vec![a, b],
2509+ Ty::Fun(
2510+ Box::new(Ty::Text),
2511+ Box::new(Ty::Fun(Box::new(Ty::Var(a)), Box::new(io_ty))),
2512+ ),
2513+ ),
2514+ );
2515+
2516+ // fetchWith : ∀a b c. Text -> c -> a -> IO {network} (Result ... b)
2517+ let a2 = self.fresh_var();
2518+ let b2 = self.fresh_var();
2519+ let c2 = self.fresh_var();
2520+ let result_ty2 = Ty::Con("Result".into(), vec![err_ty, Ty::Var(b2)]);
2521+ let io_ty2 = Ty::IO(BTreeSet::from([IoEffect::Network]), Box::new(result_ty2));
2522+ self.bind_top(
2523+ "fetchWith",
2524+ Scheme::poly(
2525+ vec![a2, b2, c2],
2526+ Ty::Fun(
2527+ Box::new(Ty::Text),
2528+ Box::new(Ty::Fun(
2529+ Box::new(Ty::Var(c2)),
2530+ Box::new(Ty::Fun(Box::new(Ty::Var(a2)), Box::new(io_ty2))),
2531+ )),
2532+ ),
2533+ ),
2534+ );
2535+ }
2536+
23922537 // ── Standard library ─────────────────────────────────────
23932538
23942539 // filter : ∀a. (a -> Bool) -> [a] -> [a]
@@ -3940,3 +4085,47 @@ mod tests {
39404085 assert!(diags.is_empty(), "unexpected errors: {:?}", diags);
39414086 }
39424087}
4088+
4089+/// Extract the constructor name from a `fetch url (Ctor {..})` or
4090+/// `fetch url opts (Ctor {..})` expression tree. Returns `None` if
4091+/// the expression is not a fetch call with a constructor argument.
4092+fn fetch_ctor_name(expr: &ast::Expr) -> Option<&str> {
4093+ let ast::ExprKind::App { func, arg } = &expr.node else {
4094+ return None;
4095+ };
4096+ // The last argument should be a constructor application
4097+ let ctor_name = match &arg.node {
4098+ ast::ExprKind::App { func: ctor_func, .. } => {
4099+ if let ast::ExprKind::Constructor(name) = &ctor_func.node {
4100+ name.as_str()
4101+ } else {
4102+ return None;
4103+ }
4104+ }
4105+ ast::ExprKind::Constructor(name) => name.as_str(),
4106+ _ => return None,
4107+ };
4108+ // Walk the function chain to find Var("fetch") or Var("fetchWith") at the root
4109+ let mut f = func.as_ref();
4110+ loop {
4111+ match &f.node {
4112+ ast::ExprKind::Var(name) if name == "fetch" || name == "fetchWith" => {
4113+ return Some(ctor_name);
4114+ }
4115+ ast::ExprKind::App { func: inner, .. } => f = inner.as_ref(),
4116+ _ => return None,
4117+ }
4118+ }
4119+}
4120+
4121+/// Uncurry a fetch application into its root function and arguments.
4122+fn uncurry_fetch<'a>(expr: &'a ast::Expr) -> (&'a ast::Expr, Vec<&'a ast::Expr>) {
4123+ match &expr.node {
4124+ ast::ExprKind::App { func, arg } => {
4125+ let (f, mut args) = uncurry_fetch(func);
4126+ args.push(arg);
4127+ (f, args)
4128+ }
4129+ _ => (expr, Vec::new()),
4130+ }
4131+}
crates/knot-runtime/Cargo.toml+1
@@ -18,3 +18,4 @@ getrandom = "0.3"
1818ed25519-dalek = { version = "2", default-features = false }
1919x25519-dalek = { version = "2", features = ["static_secrets"] }
2020chacha20poly1305 = "0.10"
21+ureq = { workspace = true }
crates/knot-runtime/src/lib.rs+259
@@ -6587,6 +6587,265 @@ pub extern "C" fn knot_http_listen(
65876587 }
65886588}
65896589
6590+// ── HTTP client (fetch) ─────────────────────────────────────────
6591+
6592+#[unsafe(no_mangle)]
6593+pub extern "C" fn knot_http_fetch_io(
6594+ base_url: *mut Value,
6595+ method_ptr: *const u8,
6596+ method_len: usize,
6597+ path_ptr: *const u8,
6598+ path_len: usize,
6599+ payload: *mut Value,
6600+ body_ptr: *const u8,
6601+ body_len: usize,
6602+ query_ptr: *const u8,
6603+ query_len: usize,
6604+ resp_ptr: *const u8,
6605+ resp_len: usize,
6606+ headers: *mut Value,
6607+) -> *mut Value {
6608+ let method = alloc(Value::Text(
6609+ unsafe { str_from_raw(method_ptr, method_len) }.to_string(),
6610+ ));
6611+ let path = alloc(Value::Text(
6612+ unsafe { str_from_raw(path_ptr, path_len) }.to_string(),
6613+ ));
6614+ let body_desc = alloc(Value::Text(
6615+ unsafe { str_from_raw(body_ptr, body_len) }.to_string(),
6616+ ));
6617+ let query_desc = alloc(Value::Text(
6618+ unsafe { str_from_raw(query_ptr, query_len) }.to_string(),
6619+ ));
6620+ let resp_desc = alloc(Value::Text(
6621+ unsafe { str_from_raw(resp_ptr, resp_len) }.to_string(),
6622+ ));
6623+
6624+ // Env record — fields sorted alphabetically for index-based access
6625+ // 0: base_url, 1: body_desc, 2: headers, 3: method, 4: path, 5: payload, 6: query_desc, 7: resp_desc
6626+ let env = alloc(Value::Record(vec![
6627+ RecordField { name: "base_url".into(), value: base_url },
6628+ RecordField { name: "body_desc".into(), value: body_desc },
6629+ RecordField { name: "headers".into(), value: headers },
6630+ RecordField { name: "method".into(), value: method },
6631+ RecordField { name: "path".into(), value: path },
6632+ RecordField { name: "payload".into(), value: payload },
6633+ RecordField { name: "query_desc".into(), value: query_desc },
6634+ RecordField { name: "resp_desc".into(), value: resp_desc },
6635+ ]));
6636+
6637+ extern "C" fn fetch_thunk(_db: *mut c_void, env: *mut Value) -> *mut Value {
6638+ let base_url = knot_record_field_by_index(env, 0);
6639+ let body_desc = knot_record_field_by_index(env, 1);
6640+ let headers = knot_record_field_by_index(env, 2);
6641+ let method = knot_record_field_by_index(env, 3);
6642+ let path = knot_record_field_by_index(env, 4);
6643+ let payload = knot_record_field_by_index(env, 5);
6644+ let query_desc = knot_record_field_by_index(env, 6);
6645+ let resp_desc = knot_record_field_by_index(env, 7);
6646+
6647+ let base = match unsafe { as_ref(base_url) } {
6648+ Value::Text(s) => s.clone(),
6649+ _ => panic!("knot runtime: fetch expected Text base URL"),
6650+ };
6651+ let path_pattern = match unsafe { as_ref(path) } {
6652+ Value::Text(s) => s.clone(),
6653+ _ => panic!("knot runtime: fetch expected Text path"),
6654+ };
6655+ let method_str = match unsafe { as_ref(method) } {
6656+ Value::Text(s) => s.clone(),
6657+ _ => panic!("knot runtime: fetch expected Text method"),
6658+ };
6659+
6660+ // Build URL with path param substitution
6661+ let url = fetch_build_url(&base, &path_pattern, payload);
6662+
6663+ // Build body JSON from body field descriptor
6664+ let body_json = match unsafe { as_ref(body_desc) } {
6665+ Value::Text(s) if !s.is_empty() => Some(fetch_build_body(s, payload)),
6666+ _ => None,
6667+ };
6668+
6669+ // Build query string from query field descriptor
6670+ let query_string = match unsafe { as_ref(query_desc) } {
6671+ Value::Text(s) if !s.is_empty() => Some(fetch_build_query(s, payload)),
6672+ _ => None,
6673+ };
6674+
6675+ let full_url = match &query_string {
6676+ Some(qs) if !qs.is_empty() => format!("{}?{}", url, qs),
6677+ _ => url,
6678+ };
6679+
6680+ // Build ureq request
6681+ let mut request = match method_str.as_str() {
6682+ "GET" => ureq::get(&full_url),
6683+ "POST" => ureq::post(&full_url),
6684+ "PUT" => ureq::put(&full_url),
6685+ "DELETE" => ureq::delete(&full_url),
6686+ "PATCH" => ureq::patch(&full_url),
6687+ _ => panic!("knot runtime: fetch unsupported method: {}", method_str),
6688+ };
6689+
6690+ // Set headers from relation
6691+ if !headers.is_null() {
6692+ if let Value::Relation(rows) = unsafe { as_ref(headers) } {
6693+ for row in rows {
6694+ let n = fetch_record_text_field(*row, "name");
6695+ let v = fetch_record_text_field(*row, "value");
6696+ request = request.set(&n, &v);
6697+ }
6698+ }
6699+ }
6700+
6701+ // Send request
6702+ let result = match body_json {
6703+ Some(ref json) => request
6704+ .set("Content-Type", "application/json")
6705+ .send_string(json),
6706+ None => request.call(),
6707+ };
6708+
6709+ // Build Result ADT
6710+ match result {
6711+ Ok(response) => {
6712+ let _status = response.status();
6713+ let body_text = response.into_string().unwrap_or_default();
6714+ let has_resp_schema = matches!(unsafe { as_ref(resp_desc) }, Value::Text(s) if !s.is_empty());
6715+ let parsed = if has_resp_schema {
6716+ let (val, _) = parse_json_value(&body_text);
6717+ val
6718+ } else {
6719+ alloc(Value::Text(body_text))
6720+ };
6721+ // Ok {value: parsed}
6722+ alloc(Value::Constructor(
6723+ "Ok".into(),
6724+ alloc(Value::Record(vec![
6725+ RecordField { name: "value".into(), value: parsed },
6726+ ])),
6727+ ))
6728+ }
6729+ Err(ureq::Error::Status(code, response)) => {
6730+ let body_text = response.into_string().unwrap_or_default();
6731+ fetch_build_err(code, &body_text)
6732+ }
6733+ Err(ureq::Error::Transport(e)) => {
6734+ fetch_build_err(0, &format!("Network error: {}", e))
6735+ }
6736+ }
6737+ }
6738+
6739+ alloc(Value::IO(fetch_thunk as *const u8, env))
6740+}
6741+
6742+/// Build a full URL by substituting `{name:type}` path params from a record.
6743+fn fetch_build_url(base: &str, path_pattern: &str, payload: *mut Value) -> String {
6744+ let mut url = base.trim_end_matches('/').to_string();
6745+ let mut remaining = path_pattern;
6746+ while let Some(start) = remaining.find('{') {
6747+ url.push_str(&remaining[..start]);
6748+ let end = remaining.find('}').expect("unmatched { in path pattern");
6749+ let param = &remaining[start + 1..end];
6750+ let (name, _ty) = param.split_once(':').unwrap_or((param, "text"));
6751+ let field_val = knot_record_field(payload, name.as_ptr(), name.len());
6752+ url.push_str(&fetch_value_to_text(field_val));
6753+ remaining = &remaining[end + 1..];
6754+ }
6755+ url.push_str(remaining);
6756+ url
6757+}
6758+
6759+/// Build a JSON body string from a field descriptor and record payload.
6760+fn fetch_build_body(body_desc: &str, payload: *mut Value) -> String {
6761+ let mut json = String::from("{");
6762+ for (i, field_desc) in body_desc.split(',').enumerate() {
6763+ if field_desc.is_empty() {
6764+ continue;
6765+ }
6766+ let (name, _ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
6767+ let field_val = knot_record_field(payload, name.as_ptr(), name.len());
6768+ if i > 0 {
6769+ json.push(',');
6770+ }
6771+ json.push('"');
6772+ json.push_str(name);
6773+ json.push_str("\":");
6774+ json.push_str(&value_to_json(field_val));
6775+ }
6776+ json.push('}');
6777+ json
6778+}
6779+
6780+/// Build a query string from a field descriptor and record payload.
6781+fn fetch_build_query(query_desc: &str, payload: *mut Value) -> String {
6782+ let mut parts = Vec::new();
6783+ for field_desc in query_desc.split(',') {
6784+ if field_desc.is_empty() {
6785+ continue;
6786+ }
6787+ let (name, _ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
6788+ let field_val = knot_record_field(payload, name.as_ptr(), name.len());
6789+ let val_str = fetch_value_to_text(field_val);
6790+ // Percent-encode the value
6791+ let encoded: String = val_str
6792+ .bytes()
6793+ .flat_map(|b| match b {
6794+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
6795+ vec![b as char]
6796+ }
6797+ _ => format!("%{:02X}", b).chars().collect(),
6798+ })
6799+ .collect();
6800+ parts.push(format!("{}={}", name, encoded));
6801+ }
6802+ parts.join("&")
6803+}
6804+
6805+/// Convert a Knot value to its text representation for URL params.
6806+fn fetch_value_to_text(v: *mut Value) -> String {
6807+ match unsafe { as_ref(v) } {
6808+ Value::Int(n) => n.to_string(),
6809+ Value::Float(n) => n.to_string(),
6810+ Value::Text(s) => s.clone(),
6811+ Value::Bool(b) => b.to_string(),
6812+ _ => panic!(
6813+ "knot runtime: cannot convert {} to text for URL parameter",
6814+ type_name(v)
6815+ ),
6816+ }
6817+}
6818+
6819+/// Extract a Text field from a record by name.
6820+fn fetch_record_text_field(record: *mut Value, field: &str) -> String {
6821+ let val = knot_record_field(record, field.as_ptr(), field.len());
6822+ match unsafe { as_ref(val) } {
6823+ Value::Text(s) => s.clone(),
6824+ _ => String::new(),
6825+ }
6826+}
6827+
6828+/// Build an Err {error: {message: Text, status: Int}} value.
6829+fn fetch_build_err(status: u16, message: &str) -> *mut Value {
6830+ let error_record = alloc(Value::Record(vec![
6831+ RecordField {
6832+ name: "message".into(),
6833+ value: alloc(Value::Text(message.to_string())),
6834+ },
6835+ RecordField {
6836+ name: "status".into(),
6837+ value: alloc(Value::Int(BigInt::from(status))),
6838+ },
6839+ ]));
6840+ alloc(Value::Constructor(
6841+ "Err".into(),
6842+ alloc(Value::Record(vec![RecordField {
6843+ name: "error".into(),
6844+ value: error_record,
6845+ }])),
6846+ ))
6847+}
6848+
65906849// ── OpenAPI spec generation ──────────────────────────────────────
65916850
65926851use std::sync::Mutex;