nim-ffi/tests/unit/test_nim_native_api.nim
2026-05-19 12:43:34 +02:00

180 lines
6.1 KiB
Nim

## Demonstrates the Nim-native side of the {.ffi.} / {.ffiCtor.} macros:
## every annotated proc remains callable from Nim with its declared signature
## (`Future[Result[T, string]]`), no callbacks or CBOR buffers involved. The
## C-exported wrapper exists in parallel as an overload distinguishable by
## arity — see `test_ffi_context.nim` for the C-shape callers.
import std/options
import unittest2
import results
import ffi
type Counter = object
start: int
type CounterConfig {.ffi.} = object
initial: int
type IncRequest {.ffi.} = object
by: int
type CounterState {.ffi.} = object
value: int
proc counter_create*(cfg: CounterConfig): Future[Result[Counter, string]] {.ffiCtor.} =
## Async ctor body — exercises the chronos path on the FFI thread.
await sleepAsync(1.milliseconds)
return ok(Counter(start: cfg.initial))
proc counter_value*(c: Counter): Future[Result[CounterState, string]] {.ffi.} =
## Sync body (no `await`); the Nim-facing wrapper still returns
## Future[Result[...]] so the source-level shape is preserved.
return ok(CounterState(value: c.start))
proc counter_add*(
c: Counter, req: IncRequest
): Future[Result[CounterState, string]] {.ffi.} =
## Async body with a real chronos yield.
await sleepAsync(1.milliseconds)
return ok(CounterState(value: c.start + req.by))
proc counter_compose*(c: Counter, a: int, b: int): Future[Result[int, string]] {.ffi.} =
## Multiple primitive params plus a non-object return type.
return ok(c.start + a + b)
proc counter_greet*(
c: Counter, name: Option[string]
): Future[Result[string, string]] {.ffi.} =
## Exercises Option[T] param round-trip.
let n = if name.isSome: name.get else: "anon"
return ok("hello " & n & " (start=" & $c.start & ")")
proc counter_fail*(c: Counter, reason: string): Future[Result[string, string]] {.ffi.} =
## Error path — the failure surfaces as Result.err on the caller side.
return err("rejected: " & reason)
proc counter_chain*(
c: Counter, steps: int
): Future[Result[CounterState, string]] {.ffi.} =
## Real async work: multiple awaits composing other {.ffi.} procs.
## Shows that the Nim-facing wrapper for an {.ffi.} proc is itself
## awaitable, so {.ffi.} procs can be composed naturally without ever
## touching the C-export shape.
var current = c
for i in 0 ..< steps:
await sleepAsync(1.milliseconds)
let stepRes = await counter_add(current, IncRequest(by: 1))
if stepRes.isErr:
return err(stepRes.error)
current = Counter(start: stepRes.value.value)
return ok(CounterState(value: current.start))
type RangeFilter {.ffi.} = object
lo: int
hi: int
type Pagination {.ffi.} = object
offset: int
limit: int
type Projection {.ffi.} = object
fields: seq[string]
includeTotals: bool
type QueryReport {.ffi.} = object
matched: int
returned: int
fieldsKept: seq[string]
proc counter_query*(
c: Counter, filter: RangeFilter, page: Pagination, projection: Projection
): Future[Result[QueryReport, string]] {.ffi.} =
## Three independent object-typed parameters: `filter`, `page`, `projection`.
## Verifies that the macro packs all three into one CBOR Req envelope on the
## wire and unpacks them back into the typed locals before this body runs.
if filter.hi < filter.lo:
return err("filter range is empty")
if page.limit <= 0:
return err("page.limit must be positive")
let matched = max(0, filter.hi - filter.lo + 1)
let returned = min(matched - min(matched, page.offset), page.limit)
return ok(
QueryReport(
matched: matched + c.start, # surfaces lib state in the response
returned: returned,
fieldsKept:
if projection.includeTotals:
projection.fields & @["__totals__"]
else:
projection.fields,
)
)
suite "Nim-native API for {.ffi.} / {.ffiCtor.}":
test "ffiCtor returns the user-typed lib value":
let res = waitFor counter_create(CounterConfig(initial: 7))
check res.isOk
check res.value.start == 7
test "sync .ffi. body completes via Future[Result[T, string]]":
let res = waitFor counter_value(Counter(start: 5))
check res.isOk
check res.value.value == 5
test "async .ffi. body with await":
let res = waitFor counter_add(Counter(start: 5), IncRequest(by: 3))
check res.isOk
check res.value.value == 8
test "multiple primitive params":
let res = waitFor counter_compose(Counter(start: 1), 2, 3)
check res.isOk
check res.value == 6
test "Option[string] param round-trip — some":
let res = waitFor counter_greet(Counter(start: 1), some("jamon"))
check res.isOk
check res.value == "hello jamon (start=1)"
test "Option[string] param round-trip — none":
let res = waitFor counter_greet(Counter(start: 2), none(string))
check res.isOk
check res.value == "hello anon (start=2)"
test "error result propagates as Result.err":
let res = waitFor counter_fail(Counter(start: 0), "out of cookies")
check res.isErr
check res.error == "rejected: out of cookies"
test "async .ffi. body chains multiple awaits and composes other .ffi. procs":
let res = waitFor counter_chain(Counter(start: 10), 4)
check res.isOk
check res.value.value == 14
test "chain with 0 steps returns the input unchanged":
let res = waitFor counter_chain(Counter(start: 42), 0)
check res.isOk
check res.value.value == 42
test "three complex object params travel together in one CBOR envelope":
let res = waitFor counter_query(
Counter(start: 100),
RangeFilter(lo: 1, hi: 50),
Pagination(offset: 10, limit: 25),
Projection(fields: @["id", "name"], includeTotals: true),
)
check res.isOk
check res.value.matched == 150 # filter range 50 + lib state 100
check res.value.returned == 25
check res.value.fieldsKept == @["id", "name", "__totals__"]
test "three-complex-param error path":
let res = waitFor counter_query(
Counter(start: 0),
RangeFilter(lo: 10, hi: 1), # inverted range
Pagination(offset: 0, limit: 5),
Projection(fields: @[], includeTotals: false),
)
check res.isErr
check res.error == "filter range is empty"