chore: release v0.4.1
This commit is contained in:
+1190
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
// Code generated by apigen. DO NOT EDIT.
|
||||||
|
|
||||||
|
package abi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DataHash is the canonical hash of a manifest primitives block. The generated data package bakes
|
||||||
|
// it in and an upload whose manifest does not match it is rejected, so a manifest edited without a
|
||||||
|
// regenerate never runs against stale generated types. Key order and whitespace do not matter.
|
||||||
|
// A module with no primitives hashes to the empty string.
|
||||||
|
func DataHash(primitives json.RawMessage) string {
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(primitives, &decoded); err != nil || decoded == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(decoded)
|
||||||
|
if err != nil || string(encoded) == "{}" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(encoded)
|
||||||
|
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package rudder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hub.rudder.build/rudder/rudder-module-go/abi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OwnerKindPlayer = "player"
|
||||||
|
OwnerKindProject = "project"
|
||||||
|
|
||||||
|
catalogLimit = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
type Owner = abi.Owner
|
||||||
|
|
||||||
|
func Player(id string) Owner { return Owner{Kind: OwnerKindPlayer, ID: id} }
|
||||||
|
|
||||||
|
func (c *Context) Owner() Owner {
|
||||||
|
if c.Player == nil {
|
||||||
|
return Owner{}
|
||||||
|
}
|
||||||
|
return Owner{Kind: OwnerKindPlayer, ID: c.Player.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Op struct{ Key string }
|
||||||
|
|
||||||
|
func OpKey(op []Op) string {
|
||||||
|
if len(op) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return op[0].Key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward is what a designer types into a catalog row: some currencies, some items. It is granted
|
||||||
|
// as ONE mutation, all-or-nothing: a reward that fails halfway leaves nothing behind, and one
|
||||||
|
// idempotency key makes the whole grant idempotent.
|
||||||
|
type Reward struct {
|
||||||
|
Currencies map[string]int64 `json:"currencies,omitempty"`
|
||||||
|
Items map[string]int64 `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reward) Empty() bool {
|
||||||
|
return len(r.Currencies) == 0 && len(r.Items) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price is what a catalog field of type price carries. Without it a module hand-decodes a
|
||||||
|
// json.RawMessage every time it reads the number a designer typed into the price form.
|
||||||
|
type Price struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version is a document's optimistic-concurrency token. Zero means the document does not exist,
|
||||||
|
// which is what Get reads back for a miss and what Put demands for "must not exist yet".
|
||||||
|
type Version int64
|
||||||
|
|
||||||
|
type Written struct {
|
||||||
|
Version Version `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged tells Patch not to write. Patch returns the document as it was, with no error.
|
||||||
|
var Unchanged = errors.New("rudder: document unchanged")
|
||||||
|
|
||||||
|
// IsCode reports whether err is a kernel business error carrying this code. Without it every
|
||||||
|
// module writes the same errors.As block to map one kernel code onto one of its own.
|
||||||
|
func IsCode(err error, code string) bool {
|
||||||
|
var failure *Error
|
||||||
|
return errors.As(err, &failure) && failure.Code == code
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON marshals a generated document type for a command. A type the generator emitted always
|
||||||
|
// marshals, so a failure here is a generator bug: it traps the call rather than letting the kernel
|
||||||
|
// store null.
|
||||||
|
func JSON(value any) json.RawMessage {
|
||||||
|
body, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
panic("rudder: generated document does not marshal: " + err.Error())
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp is the wire form of an optional timestamp. The ABI carries times as RFC3339 strings, and a
|
||||||
|
// nil time has to stay nil rather than become the zero year.
|
||||||
|
func Stamp(value *time.Time) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
formatted := value.UTC().Format(time.RFC3339Nano)
|
||||||
|
return &formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
func One[Result any](command any) (Result, error) {
|
||||||
|
var result Result
|
||||||
|
if err := call(command, &result); err != nil {
|
||||||
|
var zero Result
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Batch struct {
|
||||||
|
commands []json.RawMessage
|
||||||
|
targets []any
|
||||||
|
ran bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Batch) add(command any, target any) {
|
||||||
|
body, err := json.Marshal(command)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
b.commands = append(b.commands, body)
|
||||||
|
b.targets = append(b.targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sends every command in one data.exec call. The batch is all-or-nothing: the first error
|
||||||
|
// discards every command that already applied.
|
||||||
|
func (b *Batch) Run() error {
|
||||||
|
if len(b.commands) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err := Data{}.Exec(abi.DataExecParameters{Commands: b.commands})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(result.Results) != len(b.targets) {
|
||||||
|
return errors.New("rudder: data.exec returned a different number of results than commands")
|
||||||
|
}
|
||||||
|
for index, raw := range result.Results {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, b.targets[index]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ran = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Future[Value any] struct {
|
||||||
|
value Value
|
||||||
|
batch *Batch
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFuture registers a command with the batch. Value panics until Run succeeds: reading a result
|
||||||
|
// that was never fetched is an author bug, and a trap rolls the call back instead of returning a
|
||||||
|
// zero score as if it were real.
|
||||||
|
func NewFuture[Value any](batch *Batch, command any) *Future[Value] {
|
||||||
|
future := &Future[Value]{batch: batch}
|
||||||
|
batch.add(command, &future.value)
|
||||||
|
return future
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Future[Value]) Value() Value {
|
||||||
|
if !f.batch.ran {
|
||||||
|
panic("rudder: batch result read before Run")
|
||||||
|
}
|
||||||
|
return f.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command registers a raw host command with a batch, so an op with no generated accessor runs in
|
||||||
|
// the same all-or-nothing batch as the generated ones. It is NOT an escape hatch for ops the
|
||||||
|
// generator covers.
|
||||||
|
func Command(batch *Batch, command any) *Future[struct{}] {
|
||||||
|
return NewFuture[struct{}](batch, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataHash string
|
||||||
|
|
||||||
|
func SetDataHash(hash string) { dataHash = hash }
|
||||||
|
|
||||||
|
// docEntry is the wire shape of one document, the same one the generated <Name>Entry declares.
|
||||||
|
// DocPatch decodes into it because it is the only reader that does not know the generated type.
|
||||||
|
type docEntry[Value any] struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value Value `json:"value"`
|
||||||
|
Version Version `json:"version"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocPatch is get, apply, put, one retry. It lives here rather than in generated code because it is
|
||||||
|
// the only author operation with a loop in it, and generated code with a loop needs tests.
|
||||||
|
//
|
||||||
|
// Patch retries once on version_conflict, with the same idempotency key: a put that failed does
|
||||||
|
// not use up its key.
|
||||||
|
func DocPatch[Value any, Entry any](primitive string, owner Owner, key string, apply func(*Value) error, op []Op) (Entry, error) {
|
||||||
|
var entry Entry
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
current, err := One[docEntry[Value]](abi.DocGetParameters{Primitive: primitive, Owner: owner, Key: key})
|
||||||
|
if err != nil {
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
value := current.Value
|
||||||
|
if err := apply(&value); err != nil {
|
||||||
|
if errors.Is(err, Unchanged) {
|
||||||
|
return asEntry[Entry](key, current.Value, current.Version, current.UpdatedAt), nil
|
||||||
|
}
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
written, err := One[Written](abi.DocPutParameters{
|
||||||
|
Primitive: primitive, Owner: owner, Key: key,
|
||||||
|
Data: JSON(value), Version: int64(current.Version), OpKey: OpKey(op),
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return asEntry[Entry](key, value, written.Version, time.Time{}), nil
|
||||||
|
case !IsCode(err, abi.ErrorCodeVersionConflict):
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry, Fail(abi.ErrorCodeVersionConflict, "document "+primitive+"/"+key+" changed twice while patching")
|
||||||
|
}
|
||||||
|
|
||||||
|
// asEntry builds the generated entry type from the four fields every entry has. It goes through
|
||||||
|
// JSON because DocPatch is compiled once for a type the SDK cannot name; the generated struct is
|
||||||
|
// the same shape docEntry is, so a failure here is a generator bug and traps the call.
|
||||||
|
func asEntry[Entry any, Value any](key string, value Value, version Version, updatedAt time.Time) Entry {
|
||||||
|
var entry Entry
|
||||||
|
body, err := json.Marshal(docEntry[Value]{Key: key, Value: value, Version: version, UpdatedAt: updatedAt})
|
||||||
|
if err == nil {
|
||||||
|
err = json.Unmarshal(body, &entry)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
panic("rudder: generated document entry does not fit the wire shape: " + err.Error())
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogPage fits every catalog result: list and active answer {rows, truncated}, get answers
|
||||||
|
// {row, found}. One shape is what lets every batched catalog read return *CatalogFuture[Row].
|
||||||
|
type catalogPage struct {
|
||||||
|
Rows []json.RawMessage `json:"rows"`
|
||||||
|
Row json.RawMessage `json:"row"`
|
||||||
|
Found bool `json:"found"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p catalogPage) raw() []json.RawMessage {
|
||||||
|
if p.Found {
|
||||||
|
return []json.RawMessage{p.Row}
|
||||||
|
}
|
||||||
|
return p.Rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogAll[Row any](name string) ([]Row, error) {
|
||||||
|
return catalogRows[Row](abi.CatalogListParameters{Primitive: name, Limit: catalogLimit})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogWhere[Row any](name string, field string, equals any) ([]Row, error) {
|
||||||
|
return catalogRows[Row](abi.CatalogListParameters{
|
||||||
|
Primitive: name, Field: field, Eq: catalogEq(equals), Limit: catalogLimit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogBy[Row any](name string, field string, equals any) (Row, bool, error) {
|
||||||
|
return catalogOne[Row](abi.CatalogGetParameters{Primitive: name, Field: field, Eq: catalogEq(equals)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogActive is the rows whose schedule is open now and whose segment this owner matches, in
|
||||||
|
// the declared order, both evaluated in the kernel. A zero Owner evaluates the schedule only.
|
||||||
|
func CatalogActive[Row any](name string, owner Owner, field string, equals any) ([]Row, error) {
|
||||||
|
return catalogRows[Row](catalogActive(name, owner, field, equals, catalogLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogActiveOne[Row any](name string, owner Owner, field string, equals any) (Row, bool, error) {
|
||||||
|
return catalogOne[Row](catalogActive(name, owner, field, equals, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogActive(name string, owner Owner, field string, equals any, limit int64) abi.CatalogActiveParameters {
|
||||||
|
return abi.CatalogActiveParameters{
|
||||||
|
Primitive: name, Owner: owner, Field: field, Eq: catalogEq(equals), Limit: limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogRows[Row any](command any) ([]Row, error) {
|
||||||
|
page, err := One[catalogPage](command)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeRows[Row](page.raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogOne[Row any](command any) (Row, bool, error) {
|
||||||
|
rows, err := catalogRows[Row](command)
|
||||||
|
var row Row
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return row, false, err
|
||||||
|
}
|
||||||
|
return rows[0], true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRows[Row any](items []json.RawMessage) ([]Row, error) {
|
||||||
|
rows := make([]Row, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
var row Row
|
||||||
|
if err := json.Unmarshal(item, &row); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogFuture is the batched form of a catalog read. The rows decode when the value is read
|
||||||
|
// rather than in Run, because only the generated package knows the row type; a row that does not
|
||||||
|
// fit it is a generated-code bug, so it traps the call the way Value does before Run.
|
||||||
|
type CatalogFuture[Row any] struct {
|
||||||
|
page *Future[catalogPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCatalogFuture[Row any](batch *Batch, command any) *CatalogFuture[Row] {
|
||||||
|
return &CatalogFuture[Row]{page: NewFuture[catalogPage](batch, command)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *CatalogFuture[Row]) Value() []Row {
|
||||||
|
rows, err := decodeRows[Row](f.page.Value().raw())
|
||||||
|
if err != nil {
|
||||||
|
panic("rudder: catalog row does not fit the generated type: " + err.Error())
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *CatalogFuture[Row]) One() (Row, bool) {
|
||||||
|
rows := f.Value()
|
||||||
|
if len(rows) == 0 {
|
||||||
|
var row Row
|
||||||
|
return row, false
|
||||||
|
}
|
||||||
|
return rows[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogAllFuture[Row any](batch *Batch, name string) *CatalogFuture[Row] {
|
||||||
|
return newCatalogFuture[Row](batch, abi.CatalogListParameters{Primitive: name, Limit: catalogLimit})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogWhereFuture[Row any](batch *Batch, name string, field string, equals any) *CatalogFuture[Row] {
|
||||||
|
return newCatalogFuture[Row](batch, abi.CatalogListParameters{
|
||||||
|
Primitive: name, Field: field, Eq: catalogEq(equals), Limit: catalogLimit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogByFuture[Row any](batch *Batch, name string, field string, equals any) *CatalogFuture[Row] {
|
||||||
|
return newCatalogFuture[Row](batch, abi.CatalogGetParameters{Primitive: name, Field: field, Eq: catalogEq(equals)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogActiveFuture[Row any](batch *Batch, name string, owner Owner, field string, equals any) *CatalogFuture[Row] {
|
||||||
|
return newCatalogFuture[Row](batch, catalogActive(name, owner, field, equals, catalogLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CatalogActiveOneFuture[Row any](batch *Batch, name string, owner Owner, field string, equals any) *CatalogFuture[Row] {
|
||||||
|
return newCatalogFuture[Row](batch, catalogActive(name, owner, field, equals, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogEq(equals any) json.RawMessage {
|
||||||
|
if equals == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(equals)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant pays a reward to an owner. Concurrent grants to the same owner are safe.
|
||||||
|
func (c *Context) Grant(owner Owner, reward Reward, op ...Op) error {
|
||||||
|
return call(abi.RewardGrantParameters{
|
||||||
|
Owner: owner,
|
||||||
|
Currencies: amounts(reward.Currencies),
|
||||||
|
Items: stacks(reward.Items),
|
||||||
|
OpKey: OpKey(op),
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func amounts(values map[string]int64) []abi.Amount {
|
||||||
|
out := make([]abi.Amount, 0, len(values))
|
||||||
|
for _, currency := range sortedKeys(values) {
|
||||||
|
out = append(out, abi.Amount{Currency: currency, Amount: values[currency]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stacks(values map[string]int64) []abi.Stack {
|
||||||
|
out := make([]abi.Stack, 0, len(values))
|
||||||
|
for _, item := range sortedKeys(values) {
|
||||||
|
out = append(out, abi.Stack{Item: item, Quantity: values[item]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(values map[string]int64) []string {
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build !wasip1
|
||||||
|
|
||||||
|
package rudder
|
||||||
|
|
||||||
|
const nativeBuildMessage = "rudder modules run only when built with GOOS=wasip1 GOARCH=wasm"
|
||||||
|
|
||||||
|
func readInput() []byte {
|
||||||
|
panic(nativeBuildMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostCall(request []byte) []byte {
|
||||||
|
panic(nativeBuildMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOutput(body []byte) {
|
||||||
|
panic(nativeBuildMessage)
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package rudder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"hub.rudder.build/rudder/rudder-module-go/abi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ledger struct{}
|
||||||
|
|
||||||
|
func (Ledger) Balance(parameters abi.LedgerBalanceParameters) (*abi.LedgerBalanceResult, error) {
|
||||||
|
return callResult[abi.LedgerBalanceResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Ledger) Balances(parameters abi.LedgerBalancesParameters) (*abi.LedgerBalancesResult, error) {
|
||||||
|
return callResult[abi.LedgerBalancesResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Ledger) Credit(parameters abi.LedgerCreditParameters) (*abi.LedgerCreditResult, error) {
|
||||||
|
return callResult[abi.LedgerCreditResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Ledger) Debit(parameters abi.LedgerDebitParameters) (*abi.LedgerDebitResult, error) {
|
||||||
|
return callResult[abi.LedgerDebitResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Ledger) History(parameters abi.LedgerHistoryParameters) (*abi.LedgerHistoryResult, error) {
|
||||||
|
return callResult[abi.LedgerHistoryResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Inventory struct{}
|
||||||
|
|
||||||
|
func (Inventory) Get(parameters abi.InventoryGetParameters) (*abi.InventoryGetResult, error) {
|
||||||
|
return callResult[abi.InventoryGetResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Inventory) List(parameters abi.InventoryListParameters) (*abi.InventoryListResult, error) {
|
||||||
|
return callResult[abi.InventoryListResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Inventory) Has(parameters abi.InventoryHasParameters) (*abi.InventoryHasResult, error) {
|
||||||
|
return callResult[abi.InventoryHasResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Inventory) Grant(parameters abi.InventoryGrantParameters) error {
|
||||||
|
return call(parameters, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Inventory) Consume(parameters abi.InventoryConsumeParameters) error {
|
||||||
|
return call(parameters, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Storage struct{}
|
||||||
|
|
||||||
|
func (Storage) Get(parameters abi.StorageGetParameters) (*abi.StorageGetResult, error) {
|
||||||
|
return callResult[abi.StorageGetResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Storage) Set(parameters abi.StorageSetParameters) error {
|
||||||
|
return call(parameters, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectStorage struct{}
|
||||||
|
|
||||||
|
func (ProjectStorage) Get(parameters abi.ProjectStorageGetParameters) (*abi.ProjectStorageGetResult, error) {
|
||||||
|
return callResult[abi.ProjectStorageGetResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ProjectStorage) Set(parameters abi.ProjectStorageSetParameters) error {
|
||||||
|
return call(parameters, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Segments struct{}
|
||||||
|
|
||||||
|
func (Segments) Match(parameters abi.SegmentsMatchParameters) (*abi.SegmentsMatchResult, error) {
|
||||||
|
return callResult[abi.SegmentsMatchResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Segments) Bucket(parameters abi.SegmentsBucketParameters) (*abi.SegmentsBucketResult, error) {
|
||||||
|
return callResult[abi.SegmentsBucketResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Events struct{}
|
||||||
|
|
||||||
|
func (Events) Emit(parameters abi.EventsEmitParameters) error {
|
||||||
|
return call(parameters, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Data struct{}
|
||||||
|
|
||||||
|
func (Data) Exec(parameters abi.DataExecParameters) (*abi.DataExecResult, error) {
|
||||||
|
return callResult[abi.DataExecResult](parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Modules struct{}
|
||||||
|
|
||||||
|
func (Modules) Call(module string, function string, args any) (json.RawMessage, error) {
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result abi.ModulesCallResult
|
||||||
|
if err := call(abi.ModulesCallParameters{Module: module, Function: function, Arguments: body}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func callResult[Result any](parameters any) (*Result, error) {
|
||||||
|
var result Result
|
||||||
|
if err := call(parameters, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package rudder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hub.rudder.build/rudder/rudder-module-go/abi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
run func(ctx *Context, args json.RawMessage) (any, error)
|
||||||
|
arguments reflect.Type
|
||||||
|
result reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(function *function)
|
||||||
|
|
||||||
|
type function struct {
|
||||||
|
handler Handler
|
||||||
|
errors []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Trigger string
|
||||||
|
Player *abi.Player
|
||||||
|
Config json.RawMessage
|
||||||
|
Now time.Time
|
||||||
|
Module string
|
||||||
|
Environment string
|
||||||
|
|
||||||
|
Ledger Ledger
|
||||||
|
Inventory Inventory
|
||||||
|
Storage Storage
|
||||||
|
ProjectStorage ProjectStorage
|
||||||
|
Segments Segments
|
||||||
|
Events Events
|
||||||
|
Modules Modules
|
||||||
|
Data Data
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return e.Code + ": " + e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fail(code string, message string) error {
|
||||||
|
return &Error{Code: code, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
var functions = map[string]*function{}
|
||||||
|
|
||||||
|
func Register(name string, handler Handler, options ...Option) {
|
||||||
|
registered := &function{handler: handler, errors: []string{}}
|
||||||
|
for _, option := range options {
|
||||||
|
option(registered)
|
||||||
|
}
|
||||||
|
functions[name] = registered
|
||||||
|
}
|
||||||
|
|
||||||
|
func Errors(codes ...string) Option {
|
||||||
|
return func(function *function) {
|
||||||
|
function.errors = append(function.errors, codes...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Typed[Arguments any, Result any](handler func(ctx *Context, args Arguments) (Result, error)) Handler {
|
||||||
|
return Handler{
|
||||||
|
run: func(ctx *Context, raw json.RawMessage) (any, error) {
|
||||||
|
var args Arguments
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &args); err != nil {
|
||||||
|
return nil, Fail(abi.ErrorCodeInvalidParameters, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handler(ctx, args)
|
||||||
|
},
|
||||||
|
arguments: reflect.TypeFor[Arguments](),
|
||||||
|
result: reflect.TypeFor[Result](),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHandle() {
|
||||||
|
output, err := invoke(readInput())
|
||||||
|
if err != nil {
|
||||||
|
var failure *Error
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
failure = &Error{Code: abi.ErrorCodeInternal, Message: err.Error()}
|
||||||
|
}
|
||||||
|
output = abi.Output{Error: &abi.ErrorBody{Code: failure.Code, Message: failure.Message}}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(output)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
writeOutput(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoke(input []byte) (abi.Output, error) {
|
||||||
|
var request abi.Input
|
||||||
|
if err := json.Unmarshal(input, &request); err != nil {
|
||||||
|
return abi.Output{}, err
|
||||||
|
}
|
||||||
|
registered, ok := functions[request.Function]
|
||||||
|
if !ok {
|
||||||
|
return abi.Output{}, Fail(abi.ErrorCodeFunctionNotFound, "function "+request.Function+" is not registered")
|
||||||
|
}
|
||||||
|
now, err := time.Parse(time.RFC3339Nano, request.Context.Now)
|
||||||
|
if err != nil {
|
||||||
|
return abi.Output{}, err
|
||||||
|
}
|
||||||
|
ctx := &Context{
|
||||||
|
Trigger: request.Context.Trigger,
|
||||||
|
Player: request.Context.Player,
|
||||||
|
Config: request.Context.Config,
|
||||||
|
Now: now,
|
||||||
|
Module: request.Context.Module,
|
||||||
|
Environment: request.Context.Environment,
|
||||||
|
}
|
||||||
|
result, err := registered.handler.run(ctx, request.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return abi.Output{}, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return abi.Output{}, err
|
||||||
|
}
|
||||||
|
return abi.Output{Result: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDescribe() {
|
||||||
|
names := make([]string, 0, len(functions))
|
||||||
|
for name := range functions {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
described := make([]abi.DescribedFunction, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
registered := functions[name]
|
||||||
|
errorCodes := slices.Clone(registered.errors)
|
||||||
|
slices.Sort(errorCodes)
|
||||||
|
described = append(described, abi.DescribedFunction{
|
||||||
|
Name: name,
|
||||||
|
Input: mustSchema(name+" arguments", registered.handler.arguments),
|
||||||
|
Output: mustSchema(name+" result", registered.handler.result),
|
||||||
|
Errors: slices.Compact(errorCodes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(abi.Describe{Functions: described, DataHash: dataHash})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
writeOutput(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(parameters any, result any) error {
|
||||||
|
request, err := json.Marshal(parameters)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var response abi.CallResponse
|
||||||
|
if err := json.Unmarshal(hostCall(request), &response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.Error != nil {
|
||||||
|
return &Error{Code: response.Error.Code, Message: response.Error.Message}
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(response.Result, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package rudder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type schema struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
Nullable bool `json:"nullable,omitempty"`
|
||||||
|
Properties map[string]*schema `json:"properties,omitempty"`
|
||||||
|
Required []string `json:"required,omitempty"`
|
||||||
|
Items *schema `json:"items,omitempty"`
|
||||||
|
AdditionalProperties *schema `json:"additionalProperties,omitempty"`
|
||||||
|
Enum []any `json:"enum,omitempty"`
|
||||||
|
Minimum *float64 `json:"minimum,omitempty"`
|
||||||
|
Maximum *float64 `json:"maximum,omitempty"`
|
||||||
|
MinLength *uint64 `json:"minLength,omitempty"`
|
||||||
|
MaxLength *uint64 `json:"maxLength,omitempty"`
|
||||||
|
Pattern string `json:"pattern,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
timeType = reflect.TypeFor[time.Time]()
|
||||||
|
jsonMarshalerType = reflect.TypeFor[json.Marshaler]()
|
||||||
|
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustSchema(label string, t reflect.Type) json.RawMessage {
|
||||||
|
body, err := json.Marshal(schemaOf(t, map[reflect.Type]bool{}))
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("rudder: %s schema: %v", label, err))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaOf(t reflect.Type, visiting map[reflect.Type]bool) *schema {
|
||||||
|
if t.Kind() == reflect.Pointer {
|
||||||
|
element := schemaOf(t.Elem(), visiting)
|
||||||
|
element.Nullable = true
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
if t == timeType {
|
||||||
|
return &schema{Type: "string", Format: "date-time"}
|
||||||
|
}
|
||||||
|
if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) {
|
||||||
|
return &schema{}
|
||||||
|
}
|
||||||
|
if t.Implements(textMarshalerType) || reflect.PointerTo(t).Implements(textMarshalerType) {
|
||||||
|
return &schema{Type: "string"}
|
||||||
|
}
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.Bool:
|
||||||
|
return &schema{Type: "boolean"}
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return &schema{Type: "integer"}
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
zero := 0.0
|
||||||
|
return &schema{Type: "integer", Minimum: &zero}
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return &schema{Type: "number"}
|
||||||
|
case reflect.String:
|
||||||
|
return &schema{Type: "string"}
|
||||||
|
case reflect.Interface:
|
||||||
|
return &schema{}
|
||||||
|
case reflect.Slice:
|
||||||
|
if t.Elem().Kind() == reflect.Uint8 {
|
||||||
|
return &schema{Type: "string", Format: "byte"}
|
||||||
|
}
|
||||||
|
return &schema{Type: "array", Items: schemaOf(t.Elem(), visiting)}
|
||||||
|
case reflect.Array:
|
||||||
|
return &schema{Type: "array", Items: schemaOf(t.Elem(), visiting)}
|
||||||
|
case reflect.Map:
|
||||||
|
if t.Elem().Kind() == reflect.Interface {
|
||||||
|
return &schema{}
|
||||||
|
}
|
||||||
|
return &schema{Type: "object", AdditionalProperties: schemaOf(t.Elem(), visiting)}
|
||||||
|
case reflect.Struct:
|
||||||
|
object := &schema{Type: "object", Properties: map[string]*schema{}}
|
||||||
|
addFields(object, t, visiting)
|
||||||
|
slices.Sort(object.Required)
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
panic(fmt.Sprintf("rudder: type %s cannot be encoded as JSON", t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFields(object *schema, t reflect.Type, visiting map[reflect.Type]bool) {
|
||||||
|
if visiting[t] {
|
||||||
|
panic(fmt.Sprintf("rudder: recursive type %s is not supported in function arguments and results", t))
|
||||||
|
}
|
||||||
|
visiting[t] = true
|
||||||
|
defer delete(visiting, t)
|
||||||
|
var embedded []reflect.Type
|
||||||
|
for field := range t.Fields() {
|
||||||
|
tag := field.Tag.Get("json")
|
||||||
|
if tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, options, _ := strings.Cut(tag, ",")
|
||||||
|
fieldType := field.Type
|
||||||
|
if field.Anonymous && name == "" {
|
||||||
|
if fieldType.Kind() == reflect.Pointer {
|
||||||
|
fieldType = fieldType.Elem()
|
||||||
|
}
|
||||||
|
if fieldType.Kind() == reflect.Struct {
|
||||||
|
embedded = append(embedded, fieldType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = field.Name
|
||||||
|
}
|
||||||
|
property := schemaOf(fieldType, visiting)
|
||||||
|
applyConstraints(property, t.String()+"."+field.Name, field.Tag.Get("rudder"))
|
||||||
|
object.Properties[name] = property
|
||||||
|
if !strings.Contains(","+options+",", ",omitempty,") && !strings.Contains(","+options+",", ",omitzero,") {
|
||||||
|
object.Required = append(object.Required, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, embeddedType := range embedded {
|
||||||
|
inner := &schema{Properties: map[string]*schema{}}
|
||||||
|
addFields(inner, embeddedType, visiting)
|
||||||
|
required := map[string]bool{}
|
||||||
|
for _, name := range inner.Required {
|
||||||
|
required[name] = true
|
||||||
|
}
|
||||||
|
for name, property := range inner.Properties {
|
||||||
|
if _, ok := object.Properties[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
object.Properties[name] = property
|
||||||
|
if required[name] {
|
||||||
|
object.Required = append(object.Required, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConstraints(property *schema, field string, tag string) {
|
||||||
|
for tag != "" {
|
||||||
|
var entry string
|
||||||
|
if strings.HasPrefix(tag, "pattern=") {
|
||||||
|
entry, tag = tag, ""
|
||||||
|
} else {
|
||||||
|
entry, tag, _ = strings.Cut(tag, ",")
|
||||||
|
}
|
||||||
|
key, value, _ := strings.Cut(entry, "=")
|
||||||
|
if err := applyConstraint(property, key, value); err != nil {
|
||||||
|
panic(fmt.Sprintf("rudder: invalid rudder tag on %s: %s: %v", field, entry, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConstraint(property *schema, key string, value string) error {
|
||||||
|
numeric := property.Type == "integer" || property.Type == "number"
|
||||||
|
switch key {
|
||||||
|
case "min", "max":
|
||||||
|
if !numeric {
|
||||||
|
return fmt.Errorf("%s needs an integer or number field, got %q", key, property.Type)
|
||||||
|
}
|
||||||
|
bound, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key == "min" {
|
||||||
|
property.Minimum = &bound
|
||||||
|
} else {
|
||||||
|
property.Maximum = &bound
|
||||||
|
}
|
||||||
|
case "minLength", "maxLength":
|
||||||
|
if property.Type != "string" {
|
||||||
|
return fmt.Errorf("%s needs a string field, got %q", key, property.Type)
|
||||||
|
}
|
||||||
|
length, err := strconv.ParseUint(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key == "minLength" {
|
||||||
|
property.MinLength = &length
|
||||||
|
} else {
|
||||||
|
property.MaxLength = &length
|
||||||
|
}
|
||||||
|
case "pattern":
|
||||||
|
if property.Type != "string" {
|
||||||
|
return fmt.Errorf("pattern needs a string field, got %q", property.Type)
|
||||||
|
}
|
||||||
|
if _, err := regexp.Compile(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
property.Pattern = value
|
||||||
|
case "format":
|
||||||
|
if property.Type == "" || value == "" {
|
||||||
|
return fmt.Errorf("format needs a typed field and a value")
|
||||||
|
}
|
||||||
|
property.Format = value
|
||||||
|
case "enum":
|
||||||
|
for option := range strings.SplitSeq(value, "|") {
|
||||||
|
switch property.Type {
|
||||||
|
case "string":
|
||||||
|
property.Enum = append(property.Enum, option)
|
||||||
|
case "integer":
|
||||||
|
parsed, err := strconv.ParseInt(option, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
property.Enum = append(property.Enum, parsed)
|
||||||
|
case "number":
|
||||||
|
parsed, err := strconv.ParseFloat(option, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
property.Enum = append(property.Enum, parsed)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("enum needs a string, integer or number field, got %q", property.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown constraint %q; use min, max, minLength, maxLength, pattern, enum or format", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//go:build wasip1
|
||||||
|
|
||||||
|
package rudder
|
||||||
|
|
||||||
|
import "unsafe"
|
||||||
|
|
||||||
|
//go:wasmimport rudder_v1 input_len
|
||||||
|
func importInputLength() uint32
|
||||||
|
|
||||||
|
//go:wasmimport rudder_v1 input
|
||||||
|
func importInput(pointer unsafe.Pointer)
|
||||||
|
|
||||||
|
//go:wasmimport rudder_v1 call
|
||||||
|
func importCall(pointer unsafe.Pointer, length uint32) uint32
|
||||||
|
|
||||||
|
//go:wasmimport rudder_v1 result
|
||||||
|
func importResult(pointer unsafe.Pointer)
|
||||||
|
|
||||||
|
//go:wasmimport rudder_v1 output
|
||||||
|
func importOutput(pointer unsafe.Pointer, length uint32)
|
||||||
|
|
||||||
|
//go:wasmexport handle
|
||||||
|
func handle() {
|
||||||
|
runHandle()
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport describe
|
||||||
|
func describe() {
|
||||||
|
runDescribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInput() []byte {
|
||||||
|
input := make([]byte, importInputLength())
|
||||||
|
importInput(unsafe.Pointer(unsafe.SliceData(input)))
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostCall(request []byte) []byte {
|
||||||
|
response := make([]byte, importCall(unsafe.Pointer(unsafe.SliceData(request)), uint32(len(request))))
|
||||||
|
importResult(unsafe.Pointer(unsafe.SliceData(response)))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOutput(body []byte) {
|
||||||
|
importOutput(unsafe.Pointer(unsafe.SliceData(body)), uint32(len(body)))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user