From d7e26c49fd48a9d5dbbb026ccca30aa20e084baf Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Wed, 14 Aug 2024 08:34:20 +0200 Subject: [PATCH] feat!: server (#6) This PR adds a server component to the library. Note that it does not yet validate incoming payloads. 1. It accepts an `AgentMessage` 2. Pulls out the invocations 3. Executes each invocation, using the capability name to identify the handler to run 4. Encodes receipts for each invocation 5. Constructs a response `AgentMessage` and returns it It also has methods for constructing a server and defining invocation handler functions. BREAKING CHANGE: referencing this package at web3-storage will no longer work after this commit due to the way go.mod works --------- Co-authored-by: hannahhoward --- README.md | 29 +- client/connection.go | 40 +- core/car/car.go | 6 +- core/car/car_test.go | 2 +- core/dag/blockstore/blockstore.go | 4 +- core/delegation/datamodel/archive_test.go | 8 +- core/delegation/delegate.go | 12 +- core/delegation/delegation.go | 22 +- core/delegation/proofs.go | 6 +- core/invocation/invocation.go | 44 +-- core/invocation/ran/ran.go | 39 ++ core/ipld/block/block.go | 4 +- core/ipld/hash/sha256/sha256.go | 2 +- core/ipld/lib.go | 29 +- core/ipld/view.go | 2 +- core/iterable/iterable.go | 2 +- core/iterable/mapiterable.go | 91 +++++ core/iterable/mapiterable_test.go | 114 ++++++ core/message/datamodel/agentmessage_test.go | 8 +- core/message/message.go | 19 +- core/receipt/anyresult.ipldsch | 4 - core/receipt/datamodel/anyresult.ipldsch | 4 + core/receipt/datamodel/receipt.go | 18 + core/receipt/datamodel/receipt_test.go | 8 +- core/receipt/receipt.go | 117 +++--- core/result/datamodel/failure.go | 25 +- core/result/result.go | 192 ++++++++-- core/result/result_test.go | 400 ++++++++++++++++++++ go.mod | 6 +- principal/ed25519/signer/signer.go | 12 +- principal/ed25519/signer/signer_test.go | 27 ++ principal/ed25519/verifier/verifier.go | 6 +- principal/lib.go | 2 +- server/datamodel/errors.go | 75 ++++ server/datamodel/errors.ipldsch | 33 ++ server/error.go | 175 +++++++++ server/handler.go | 20 + server/options.go | 79 ++++ server/server.go | 297 +++++++++++++++ server/server_test.go | 189 +++++++++ server/transaction/transaction.go | 83 ++++ testing/helpers/helpers.go | 10 + transport/car/codec.go | 73 +++- transport/car/request/request.go | 24 +- transport/car/response/response.go | 20 +- transport/codec.go | 23 +- transport/http.go | 12 +- transport/http/channel.go | 7 +- transport/http/error.go | 33 ++ transport/http/response.go | 28 +- ucan/crypto/signer.go | 2 +- ucan/datamodel/payload/payload.go | 2 +- ucan/formatter/formatter.go | 6 +- ucan/lib.go | 8 +- ucan/ucan.go | 6 +- ucan/view.go | 6 +- validator/authorization.go | 3 + validator/lib.go | 10 + validator/lib_test.go | 31 ++ 59 files changed, 2275 insertions(+), 284 deletions(-) create mode 100644 core/invocation/ran/ran.go create mode 100644 core/iterable/mapiterable.go create mode 100644 core/iterable/mapiterable_test.go delete mode 100644 core/receipt/anyresult.ipldsch create mode 100644 core/receipt/datamodel/anyresult.ipldsch create mode 100644 core/result/result_test.go create mode 100644 server/datamodel/errors.go create mode 100644 server/datamodel/errors.ipldsch create mode 100644 server/error.go create mode 100644 server/handler.go create mode 100644 server/options.go create mode 100644 server/server.go create mode 100644 server/server_test.go create mode 100644 server/transaction/transaction.go create mode 100644 testing/helpers/helpers.go create mode 100644 transport/http/error.go create mode 100644 validator/authorization.go create mode 100644 validator/lib.go create mode 100644 validator/lib_test.go diff --git a/README.md b/README.md index c46d002..20a5871 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Ucanto UCAN RPC in Golang. ## Install ```console -go get github.com/web3-storage/go-ucanto +go get github.com/storacha-network/go-ucanto ``` ## Usage @@ -17,14 +17,14 @@ import ( "net/url" "ioutil" - "github.com/web3-storage/go-ucanto/client" - "github.com/web3-storage/go-ucanto/did" - ed25519 "github.com/web3-storage/go-ucanto/principal/ed25519/signer" - "github.com/web3-storage/go-ucanto/transport/car" - "github.com/web3-storage/go-ucanto/transport/http" - "github.com/web3-storage/go-ucanto/core/delegation" - "github.com/web3-storage/go-ucanto/core/invocation" - "github.com/web3-storage/go-ucanto/core/receipt" + "github.com/storacha-network/go-ucanto/client" + "github.com/storacha-network/go-ucanto/did" + ed25519 "github.com/storacha-network/go-ucanto/principal/ed25519/signer" + "github.com/storacha-network/go-ucanto/transport/car" + "github.com/storacha-network/go-ucanto/transport/http" + "github.com/storacha-network/go-ucanto/core/delegation" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/receipt" ) // service URL & DID @@ -62,9 +62,8 @@ capability := ucan.NewCapability( ) // create invocation(s) to perform a task with granted capabilities -invocations := []invocation.Invocation{ - invocation.Invoke(signer, audience, capability, delegation.WithProofs(...)) -} +inv, _ := invocation.Invoke(signer, audience, capability, delegation.WithProofs(...)) +invocations := []invocation.Invocation{inv} // send the invocation(s) to the service resp, _ := client.Execute(invocations, conn) @@ -104,15 +103,15 @@ fmt.Println(rcpt.Out().Ok()) ## API -[pkg.go.dev Reference](https://pkg.go.dev/github.com/web3-storage/go-ucanto) +[pkg.go.dev Reference](https://pkg.go.dev/github.com/storacha-network/go-ucanto) ## Related -* [Ucanto in Javascript](https://github.com/web3-storage/ucanto) +* [Ucanto in Javascript](https://github.com/storacha-network/ucanto) ## Contributing -Feel free to join in. All welcome. Please [open an issue](https://github.com/web3-storage/go-ucanto/issues)! +Feel free to join in. All welcome. Please [open an issue](https://github.com/storacha-network/go-ucanto/issues)! ## License diff --git a/client/connection.go b/client/connection.go index 6fa2530..7232cbc 100644 --- a/client/connection.go +++ b/client/connection.go @@ -5,12 +5,13 @@ import ( "fmt" "hash" - "github.com/web3-storage/go-ucanto/core/invocation" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/iterable" - "github.com/web3-storage/go-ucanto/core/message" - "github.com/web3-storage/go-ucanto/transport" - "github.com/web3-storage/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/iterable" + "github.com/storacha-network/go-ucanto/core/message" + "github.com/storacha-network/go-ucanto/transport" + "github.com/storacha-network/go-ucanto/transport/car" + "github.com/storacha-network/go-ucanto/ucan" ) type Connection interface { @@ -25,6 +26,7 @@ type Option func(cfg *connConfig) error type connConfig struct { hasher func() hash.Hash + codec transport.OutboundCodec } // WithHasher configures the hasher factory. @@ -35,14 +37,34 @@ func WithHasher(hasher func() hash.Hash) Option { } } -func NewConnection(id ucan.Principal, codec transport.OutboundCodec, channel transport.Channel, options ...Option) (Connection, error) { - cfg := connConfig{sha256.New} +// WithOutboundCodec configures the codec used to encode requests and decode +// responses. +func WithOutboundCodec(codec transport.OutboundCodec) Option { + return func(cfg *connConfig) error { + cfg.codec = codec + return nil + } +} + +func NewConnection(id ucan.Principal, channel transport.Channel, options ...Option) (Connection, error) { + cfg := connConfig{hasher: sha256.New} for _, opt := range options { if err := opt(&cfg); err != nil { return nil, err } } - c := conn{id, codec, channel, cfg.hasher} + + hasher := cfg.hasher + if hasher == nil { + hasher = sha256.New + } + + codec := cfg.codec + if codec == nil { + codec = car.NewCAROutboundCodec() + } + + c := conn{id, codec, channel, hasher} return &c, nil } diff --git a/core/car/car.go b/core/car/car.go index 5d5ef1d..aec721e 100644 --- a/core/car/car.go +++ b/core/car/car.go @@ -10,9 +10,9 @@ import ( ipldcar "github.com/ipld/go-car" "github.com/ipld/go-car/util" cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/iterable" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/iterable" ) // ContentType is the value the HTTP Content-Type header should have for CARs. diff --git a/core/car/car_test.go b/core/car/car_test.go index d2f72ad..d885198 100644 --- a/core/car/car_test.go +++ b/core/car/car_test.go @@ -8,7 +8,7 @@ import ( "github.com/ipfs/go-cid" cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/web3-storage/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/ipld" ) type fixture struct { diff --git a/core/dag/blockstore/blockstore.go b/core/dag/blockstore/blockstore.go index bfb6a91..eedab90 100644 --- a/core/dag/blockstore/blockstore.go +++ b/core/dag/blockstore/blockstore.go @@ -5,8 +5,8 @@ import ( "io" "sync" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/core/iterable" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/iterable" ) type BlockReader interface { diff --git a/core/delegation/datamodel/archive_test.go b/core/delegation/datamodel/archive_test.go index c35f9d0..1bfa868 100644 --- a/core/delegation/datamodel/archive_test.go +++ b/core/delegation/datamodel/archive_test.go @@ -5,10 +5,10 @@ import ( "github.com/ipfs/go-cid" cidlink "github.com/ipld/go-ipld-prime/linking/cid" - adm "github.com/web3-storage/go-ucanto/core/delegation/datamodel" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" + adm "github.com/storacha-network/go-ucanto/core/delegation/datamodel" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" ) func TestEncodeDecode(t *testing.T) { diff --git a/core/delegation/delegate.go b/core/delegation/delegate.go index 13a18a5..eaf54e7 100644 --- a/core/delegation/delegate.go +++ b/core/delegation/delegate.go @@ -3,12 +3,12 @@ package delegation import ( "fmt" - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" - "github.com/web3-storage/go-ucanto/ucan" - udm "github.com/web3-storage/go-ucanto/ucan/datamodel/ucan" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" + "github.com/storacha-network/go-ucanto/ucan" + udm "github.com/storacha-network/go-ucanto/ucan/datamodel/ucan" ) // Option is an option configuring a UCAN delegation. diff --git a/core/delegation/delegation.go b/core/delegation/delegation.go index 283531c..d6349c3 100644 --- a/core/delegation/delegation.go +++ b/core/delegation/delegation.go @@ -6,17 +6,17 @@ import ( "io" "sync" - "github.com/web3-storage/go-ucanto/core/car" - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - adm "github.com/web3-storage/go-ucanto/core/delegation/datamodel" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" - "github.com/web3-storage/go-ucanto/core/iterable" - "github.com/web3-storage/go-ucanto/ucan" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" - udm "github.com/web3-storage/go-ucanto/ucan/datamodel/ucan" + "github.com/storacha-network/go-ucanto/core/car" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + adm "github.com/storacha-network/go-ucanto/core/delegation/datamodel" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" + "github.com/storacha-network/go-ucanto/core/iterable" + "github.com/storacha-network/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" + udm "github.com/storacha-network/go-ucanto/ucan/datamodel/ucan" ) // Delagation is a materialized view of a UCAN delegation, which can be encoded diff --git a/core/delegation/proofs.go b/core/delegation/proofs.go index ae82e21..788b455 100644 --- a/core/delegation/proofs.go +++ b/core/delegation/proofs.go @@ -1,9 +1,9 @@ package delegation import ( - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/ucan" ) type Proof struct { diff --git a/core/invocation/invocation.go b/core/invocation/invocation.go index 0213a4f..9714701 100644 --- a/core/invocation/invocation.go +++ b/core/invocation/invocation.go @@ -1,10 +1,10 @@ package invocation import ( - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - "github.com/web3-storage/go-ucanto/core/delegation" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/delegation" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/ucan" ) // Invocation represents a UCAN that can be presented to a service provider to @@ -32,37 +32,7 @@ type IssuedInvocation interface { Invocation } -func Invoke(issuer ucan.Signer, audience ucan.Principal, capability ucan.Capability[ucan.CaveatBuilder], options ...delegation.Option) (IssuedInvocation, error) { - return delegation.Delegate(issuer, audience, []ucan.Capability[ucan.CaveatBuilder]{capability}, options...) -} - -type Ran struct { - invocation Invocation - link ucan.Link -} - -func (r Ran) Invocation() (Invocation, bool) { - return r.invocation, r.invocation != nil -} - -func (r Ran) Link() ucan.Link { - if r.invocation != nil { - return r.invocation.Link() - } - return r.link -} - -func FromInvocation(invocation Invocation) Ran { - return Ran{invocation, nil} -} - -func FromLink(link ucan.Link) Ran { - return Ran{nil, link} -} - -func (r Ran) WriteInto(bs blockstore.BlockWriter) (ipld.Link, error) { - if invocation, ok := r.Invocation(); ok { - return r.Link(), blockstore.WriteInto(invocation, bs) - } - return r.Link(), nil +func Invoke[C ucan.CaveatBuilder](issuer ucan.Signer, audience ucan.Principal, capability ucan.Capability[C], options ...delegation.Option) (IssuedInvocation, error) { + bcap := ucan.NewCapability(capability.Can(), capability.With(), ucan.CaveatBuilder(capability.Nb())) + return delegation.Delegate(issuer, audience, []ucan.Capability[ucan.CaveatBuilder]{bcap}, options...) } diff --git a/core/invocation/ran/ran.go b/core/invocation/ran/ran.go new file mode 100644 index 0000000..d5e5631 --- /dev/null +++ b/core/invocation/ran/ran.go @@ -0,0 +1,39 @@ +package ran + +import ( + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/ucan" +) + +type Ran struct { + invocation invocation.Invocation + link ucan.Link +} + +func (r Ran) Invocation() (invocation.Invocation, bool) { + return r.invocation, r.invocation != nil +} + +func (r Ran) Link() ucan.Link { + if r.invocation != nil { + return r.invocation.Link() + } + return r.link +} + +func FromInvocation(invocation invocation.Invocation) Ran { + return Ran{invocation, nil} +} + +func FromLink(link ucan.Link) Ran { + return Ran{nil, link} +} + +func (r Ran) WriteInto(bs blockstore.BlockWriter) (ipld.Link, error) { + if invocation, ok := r.Invocation(); ok { + return r.Link(), blockstore.WriteInto(invocation, bs) + } + return r.Link(), nil +} diff --git a/core/ipld/block/block.go b/core/ipld/block/block.go index ada2120..c32190b 100644 --- a/core/ipld/block/block.go +++ b/core/ipld/block/block.go @@ -8,8 +8,8 @@ import ( "github.com/ipld/go-ipld-prime" cidlink "github.com/ipld/go-ipld-prime/linking/cid" "github.com/ipld/go-ipld-prime/schema" - "github.com/web3-storage/go-ucanto/core/ipld/codec" - "github.com/web3-storage/go-ucanto/core/ipld/hash" + "github.com/storacha-network/go-ucanto/core/ipld/codec" + "github.com/storacha-network/go-ucanto/core/ipld/hash" ) type Block interface { diff --git a/core/ipld/hash/sha256/sha256.go b/core/ipld/hash/sha256/sha256.go index 68e7f07..cbcf19a 100644 --- a/core/ipld/hash/sha256/sha256.go +++ b/core/ipld/hash/sha256/sha256.go @@ -4,7 +4,7 @@ import ( "crypto/sha256" "github.com/multiformats/go-multihash" - "github.com/web3-storage/go-ucanto/core/ipld/hash" + "github.com/storacha-network/go-ucanto/core/ipld/hash" ) // sha2-256 diff --git a/core/ipld/lib.go b/core/ipld/lib.go index 06ccb1a..b0a20d7 100644 --- a/core/ipld/lib.go +++ b/core/ipld/lib.go @@ -1,10 +1,37 @@ package ipld import ( + "errors" + "github.com/ipld/go-ipld-prime" - "github.com/web3-storage/go-ucanto/core/ipld/block" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/ipld/go-ipld-prime/schema" + "github.com/storacha-network/go-ucanto/core/ipld/block" ) type Link = ipld.Link type Block = block.Block type Node = ipld.Node + +// Builder can be modeled as an IPLD data and provides a `Build“ method to +// build itself into a `datamodel.Node`. +type Builder interface { + Build() (Node, error) +} + +// WrapWithRecovery behaves like bindnode.Wrap but converts panics into errors +func WrapWithRecovery(ptrVal interface{}, typ schema.Type) (nd Node, err error) { + defer func() { + if r := recover(); r != nil { + if asStr, ok := r.(string); ok { + err = errors.New(asStr) + } else if asErr, ok := r.(error); ok { + err = asErr + } else { + err = errors.New("unknown panic building node") + } + } + }() + nd = bindnode.Wrap(ptrVal, typ) + return +} diff --git a/core/ipld/view.go b/core/ipld/view.go index da9026b..814a7c2 100644 --- a/core/ipld/view.go +++ b/core/ipld/view.go @@ -1,7 +1,7 @@ package ipld import ( - "github.com/web3-storage/go-ucanto/core/iterable" + "github.com/storacha-network/go-ucanto/core/iterable" ) // View represents a materialized IPLD DAG View, which provides a generic diff --git a/core/iterable/iterable.go b/core/iterable/iterable.go index b7fb165..669f2e9 100644 --- a/core/iterable/iterable.go +++ b/core/iterable/iterable.go @@ -22,7 +22,7 @@ func NewIterator[T any](next func() (T, error)) Iterator[T] { return &iterator[T]{next} } -func From[T any](slice []T) Iterator[T] { +func From[Slice ~[]T, T any](slice Slice) Iterator[T] { i := 0 return NewIterator(func() (T, error) { if i < len(slice) { diff --git a/core/iterable/mapiterable.go b/core/iterable/mapiterable.go new file mode 100644 index 0000000..b4b5bcd --- /dev/null +++ b/core/iterable/mapiterable.go @@ -0,0 +1,91 @@ +package iterable + +import "io" + +// Iterator2 returns two values with every call to Next(). +// The error will be set to io.EOF when the iterator is complete. +type Iterator2[K any, V any] interface { + Next() (K, V, error) +} + +type iterator2[K any, V any] struct { + next func() (K, V, error) +} + +func (mit *iterator2[K, V]) Next() (K, V, error) { + return mit.next() +} + +func NewIterator2[K any, V any](next func() (K, V, error)) Iterator2[K, V] { + return &iterator2[K, V]{next} +} + +type mapEntry[K comparable, V any] struct { + k K + v V +} + +func FromMap[Map ~map[K]V, K comparable, V any](m Map) Iterator2[K, V] { + entries := make([]mapEntry[K, V], 0, len(m)) + for k, v := range m { + entries = append(entries, mapEntry[K, V]{k, v}) + } + i := 0 + return NewIterator2(func() (K, V, error) { + if i < len(entries) { + k := entries[i].k + v := entries[i].v + i++ + return k, v, nil + } + var k K + var v V + return k, v, io.EOF + }) +} + +func CollectMap[K comparable, V any](mit Iterator2[K, V]) (map[K]V, error) { + items := make(map[K]V) + for { + k, v, err := mit.Next() + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + items[k] = v + } + return items, nil +} + +func Concat2[K any, V any](iterators ...Iterator2[K, V]) Iterator2[K, V] { + if len(iterators) == 0 { + return NewIterator2(func() (K, V, error) { + var k K + var v V + return k, v, io.EOF + }) + } + + i := 0 + iterator := iterators[i] + return NewIterator2(func() (K, V, error) { + for { + k, v, err := iterator.Next() + if err != nil { + if err == io.EOF { + i++ + if i < len(iterators) { + iterator = iterators[i] + continue + } + } + var k K + var v V + return k, v, err + } + return k, v, nil + } + }) +} diff --git a/core/iterable/mapiterable_test.go b/core/iterable/mapiterable_test.go new file mode 100644 index 0000000..2ff157b --- /dev/null +++ b/core/iterable/mapiterable_test.go @@ -0,0 +1,114 @@ +package iterable_test + +import ( + "errors" + "fmt" + "io" + "testing" + + "github.com/storacha-network/go-ucanto/core/iterable" + "github.com/stretchr/testify/require" +) + +func TestCollectMap(t *testing.T) { + someErr := errors.New("some error") + testCases := []struct { + name string + iterator2 func() iterable.Iterator2[string, int] + expectedMap map[string]int + expectedErr error + }{ + { + name: "converts successful iterator to expected map", + iterator2: func() iterable.Iterator2[string, int] { + count := 0 + return iterable.NewIterator2(func() (string, int, error) { + defer func() { + count++ + }() + switch count { + case 0: + return "apples", 7, nil + case 1: + return "oranges", 4, nil + case 2: + return "", 0, io.EOF + default: + return "", 0, fmt.Errorf("too many calls to iterator: %d", count+1) + } + }) + }, + expectedMap: map[string]int{"apples": 7, "oranges": 4}, + }, + { + name: "fails when iterator fails", + iterator2: func() iterable.Iterator2[string, int] { + count := 0 + return iterable.NewIterator2(func() (string, int, error) { + defer func() { + count++ + }() + switch count { + case 0: + return "apples", 7, nil + default: + return "", 0, fmt.Errorf("mistake iterating: %w", someErr) + } + }) + }, + expectedMap: nil, + expectedErr: someErr, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + resultMap, err := iterable.CollectMap(testCase.iterator2()) + require.Equal(t, testCase.expectedMap, resultMap) + require.ErrorIs(t, err, testCase.expectedErr) + }) + } +} + +func TestFromMap(t *testing.T) { + verifyFromMap(t, "string -> int", map[string]int{"apples": 7, "oranges": 4}) + verifyFromMap(t, "int -> bool", map[int]bool{7: true, 4: false, 3: true}) + verifyFromMap(t, "any -> any", map[any]any{ + 7: true, + 4: "apples", + "oranges": struct{ head string }{head: "bucket"}, + }) +} + +func TestRoundtrip(t *testing.T) { + roundTrip(t, "string -> int", map[string]int{"apples": 7, "oranges": 4}) + roundTrip(t, "int -> bool", map[int]bool{7: true, 4: false, 3: true}) + roundTrip(t, "any -> any", map[any]any{ + 7: true, + 4: "apples", + "oranges": struct{ head string }{head: "bucket"}, + }) +} + +func verifyFromMap[K comparable, V any](t *testing.T, testCase string, inputMap map[K]V) { + t.Run(testCase, func(t *testing.T) { + iterator := iterable.FromMap(inputMap) + outputMap := make(map[K]V, len(inputMap)) + for { + k, v, err := iterator.Next() + if err != nil { + require.ErrorIs(t, err, io.EOF) + require.Equal(t, inputMap, outputMap) + return + } + outputMap[k] = v + } + }) +} + +func roundTrip[K comparable, V any](t *testing.T, testCase string, inputMap map[K]V) { + t.Run(testCase, func(t *testing.T) { + outputMap, err := iterable.CollectMap(iterable.FromMap(inputMap)) + require.NoError(t, err) + require.Equal(t, inputMap, outputMap) + }) +} diff --git a/core/message/datamodel/agentmessage_test.go b/core/message/datamodel/agentmessage_test.go index 4e25ef3..bfa898b 100644 --- a/core/message/datamodel/agentmessage_test.go +++ b/core/message/datamodel/agentmessage_test.go @@ -6,10 +6,10 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" - mdm "github.com/web3-storage/go-ucanto/core/message/datamodel" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" + mdm "github.com/storacha-network/go-ucanto/core/message/datamodel" ) func TestEncodeDecode(t *testing.T) { diff --git a/core/message/message.go b/core/message/message.go index a268bb9..5f7d85c 100644 --- a/core/message/message.go +++ b/core/message/message.go @@ -3,15 +3,15 @@ package message import ( "fmt" - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - "github.com/web3-storage/go-ucanto/core/invocation" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" - "github.com/web3-storage/go-ucanto/core/iterable" - mdm "github.com/web3-storage/go-ucanto/core/message/datamodel" - "github.com/web3-storage/go-ucanto/core/receipt" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" + "github.com/storacha-network/go-ucanto/core/iterable" + mdm "github.com/storacha-network/go-ucanto/core/message/datamodel" + "github.com/storacha-network/go-ucanto/core/receipt" ) type AgentMessage interface { @@ -102,6 +102,7 @@ func Build(invocations []invocation.Invocation, receipts []receipt.AnyReceipt) ( } key := receipt.Ran().Link().String() + report.Keys = append(report.Keys, key) if _, ok := report.Values[key]; !ok { report.Values[key] = receipt.Root().Link() } diff --git a/core/receipt/anyresult.ipldsch b/core/receipt/anyresult.ipldsch deleted file mode 100644 index 8f66737..0000000 --- a/core/receipt/anyresult.ipldsch +++ /dev/null @@ -1,4 +0,0 @@ -type Result union { - | any "ok" - | any "error" -} representation keyed diff --git a/core/receipt/datamodel/anyresult.ipldsch b/core/receipt/datamodel/anyresult.ipldsch new file mode 100644 index 0000000..3018e0e --- /dev/null +++ b/core/receipt/datamodel/anyresult.ipldsch @@ -0,0 +1,4 @@ +type Result struct { + ok optional Any + error optional Any +} diff --git a/core/receipt/datamodel/receipt.go b/core/receipt/datamodel/receipt.go index 83d3187..0a3927c 100644 --- a/core/receipt/datamodel/receipt.go +++ b/core/receipt/datamodel/receipt.go @@ -3,6 +3,7 @@ package datamodel import ( "bytes" _ "embed" + "fmt" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/datamodel" @@ -12,6 +13,23 @@ import ( //go:embed receipt.ipldsch var receipt []byte +//go:embed anyresult.ipldsch +var anyResultSchema []byte + +var anyReceiptTs *schema.TypeSystem + +func init() { + ts, err := NewReceiptModelType(anyResultSchema) + if err != nil { + panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + } + anyReceiptTs = ts.TypeSystem() +} + +func TypeSystem() *schema.TypeSystem { + return anyReceiptTs +} + type ReceiptModel[O any, X any] struct { Ocm OutcomeModel[O, X] Sig []byte diff --git a/core/receipt/datamodel/receipt_test.go b/core/receipt/datamodel/receipt_test.go index 2c99358..603a80f 100644 --- a/core/receipt/datamodel/receipt_test.go +++ b/core/receipt/datamodel/receipt_test.go @@ -5,10 +5,10 @@ import ( "github.com/ipfs/go-cid" cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" - rdm "github.com/web3-storage/go-ucanto/core/receipt/datamodel" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" + rdm "github.com/storacha-network/go-ucanto/core/receipt/datamodel" ) type resultOk struct { diff --git a/core/receipt/receipt.go b/core/receipt/receipt.go index 26f7743..83cdc88 100644 --- a/core/receipt/receipt.go +++ b/core/receipt/receipt.go @@ -6,21 +6,21 @@ import ( "fmt" "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - "github.com/web3-storage/go-ucanto/core/delegation" - "github.com/web3-storage/go-ucanto/core/invocation" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/core/ipld/block" - "github.com/web3-storage/go-ucanto/core/ipld/codec/cbor" - "github.com/web3-storage/go-ucanto/core/ipld/hash/sha256" - "github.com/web3-storage/go-ucanto/core/iterable" - rdm "github.com/web3-storage/go-ucanto/core/receipt/datamodel" - "github.com/web3-storage/go-ucanto/core/result" - "github.com/web3-storage/go-ucanto/did" - "github.com/web3-storage/go-ucanto/ucan" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/delegation" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/invocation/ran" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/ipld/block" + "github.com/storacha-network/go-ucanto/core/ipld/codec/cbor" + "github.com/storacha-network/go-ucanto/core/ipld/hash/sha256" + "github.com/storacha-network/go-ucanto/core/iterable" + rdm "github.com/storacha-network/go-ucanto/core/receipt/datamodel" + "github.com/storacha-network/go-ucanto/core/result" + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" ) type Effects interface { @@ -29,7 +29,7 @@ type Effects interface { } // Receipt represents a view of the invocation receipt. This interface provides -// an ergonomic API and allows you to reference linked IPLD objects of they are +// an ergonomic API and allows you to reference linked IPLD objects if they are // included in the source DAG. type Receipt[O, X any] interface { ipld.View @@ -42,24 +42,19 @@ type Receipt[O, X any] interface { Signature() signature.SignatureView } -type results[O, X any] struct { - model *rdm.ResultModel[O, X] +func toResultModel[O, X any](res result.Result[O, X]) rdm.ResultModel[O, X] { + return result.MatchResultR1(res, func(ok O) rdm.ResultModel[O, X] { + return rdm.ResultModel[O, X]{Ok: &ok, Err: nil} + }, func(err X) rdm.ResultModel[O, X] { + return rdm.ResultModel[O, X]{Ok: nil, Err: &err} + }) } -func (r results[O, X]) Error() (X, bool) { - if r.model.Err != nil { - return *r.model.Err, true +func fromResultModel[O, X any](resultModel rdm.ResultModel[O, X]) result.Result[O, X] { + if resultModel.Ok != nil { + return result.Ok[O, X](*resultModel.Ok) } - var x X - return x, false -} - -func (r results[O, X]) Ok() (O, bool) { - if r.model.Ok != nil { - return *r.model.Ok, true - } - var o O - return o, false + return result.Error[O, X](*resultModel.Err) } type effects struct { @@ -126,7 +121,7 @@ func (r *receipt[O, X]) Meta() map[string]any { } func (r *receipt[O, X]) Out() result.Result[O, X] { - return results[O, X]{&r.data.Ocm.Out} + return fromResultModel(r.data.Ocm.Out) } func (r *receipt[O, X]) Ran() invocation.Invocation { @@ -193,22 +188,7 @@ func NewReceiptReader[O, X any](resultschema []byte) (ReceiptReader[O, X], error return &receiptReader[O, X]{typ}, nil } -type AnyReceipt Receipt[datamodel.Node, datamodel.Node] - -var ( - anyReceiptTs *schema.TypeSystem -) - -//go:embed anyresult.ipldsch -var anyResultSchema []byte - -func init() { - ts, err := rdm.NewReceiptModelType(anyResultSchema) - if err != nil { - panic(fmt.Errorf("failed to load IPLD schema: %s", err)) - } - anyReceiptTs = ts.TypeSystem() -} +type AnyReceipt Receipt[ipld.Node, ipld.Node] // Option is an option configuring a UCAN delegation. type Option func(cfg *receiptConfig) error @@ -255,17 +235,7 @@ func WithJoin(join ipld.Link) Option { } } -func wrapOrFail(value interface{}) (nd schema.TypedNode, err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("%v", r) - } - }() - nd = bindnode.Wrap(value, nil) - return -} - -func Issue(issuer ucan.Signer, result result.AnyResult, ran invocation.Ran, opts ...Option) (AnyReceipt, error) { +func Issue[O, X ipld.Builder](issuer ucan.Signer, out result.Result[O, X], ran ran.Ran, opts ...Option) (AnyReceipt, error) { cfg := receiptConfig{} for _, opt := range opts { if err := opt(&cfg); err != nil { @@ -300,7 +270,7 @@ func Issue(issuer ucan.Signer, result result.AnyResult, ran invocation.Ran, opts if cfg.meta != nil { metaModel.Values = make(map[string]datamodel.Node, len(cfg.meta)) for k, v := range cfg.meta { - nd, err := wrapOrFail(v) + nd, err := ipld.WrapWithRecovery(v, nil) if err != nil { return nil, err } @@ -309,16 +279,17 @@ func Issue(issuer ucan.Signer, result result.AnyResult, ran invocation.Ran, opts } } - resultModel := rdm.ResultModel[datamodel.Node, datamodel.Node]{} - if success, ok := result.Ok(); ok { - resultModel.Ok = &success - } - if err, ok := result.Error(); ok { - resultModel.Err = &err + nodeResult, err := result.MapResultR1(out, func(b O) (ipld.Node, error) { + return b.Build() + }, func(b X) (ipld.Node, error) { + return b.Build() + }) + if err != nil { + return nil, err } - + resultModel := toResultModel(nodeResult) issString := issuer.DID().String() - outcomeModel := rdm.OutcomeModel[datamodel.Node, datamodel.Node]{ + outcomeModel := rdm.OutcomeModel[ipld.Node, ipld.Node]{ Ran: invocationLink, Out: resultModel, Fx: effectsModel, @@ -327,28 +298,28 @@ func Issue(issuer ucan.Signer, result result.AnyResult, ran invocation.Ran, opts Prf: prooflinks, } - outcomeBytes, err := cbor.Encode(outcomeModel, anyReceiptTs.TypeByName("Outcome")) + outcomeBytes, err := cbor.Encode(&outcomeModel, rdm.TypeSystem().TypeByName("Outcome")) if err != nil { return nil, err } signature := issuer.Sign(outcomeBytes).Bytes() - receiptModel := rdm.ReceiptModel[datamodel.Node, datamodel.Node]{ + receiptModel := rdm.ReceiptModel[ipld.Node, ipld.Node]{ Ocm: outcomeModel, Sig: signature, } - rt, err := block.Encode(receiptModel, anyReceiptTs.TypeByName("Receipt"), cbor.Codec, sha256.Hasher) + rt, err := block.Encode(&receiptModel, rdm.TypeSystem().TypeByName("Receipt"), cbor.Codec, sha256.Hasher) if err != nil { - return nil, fmt.Errorf("encoding UCAN: %s", err) + return nil, fmt.Errorf("encoding receipt: %s", err) } err = bs.Put(rt) if err != nil { - return nil, fmt.Errorf("adding delegation root to store: %s", err) + return nil, fmt.Errorf("adding receipt root to store: %s", err) } - return &receipt[datamodel.Node, datamodel.Node]{ + return &receipt[ipld.Node, ipld.Node]{ rt: rt, blks: bs, data: &receiptModel, diff --git a/core/result/datamodel/failure.go b/core/result/datamodel/failure.go index e573b38..694ae32 100644 --- a/core/result/datamodel/failure.go +++ b/core/result/datamodel/failure.go @@ -4,10 +4,10 @@ import ( // to use go:embed _ "embed" "fmt" - "sync" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/schema" + ucanipld "github.com/storacha-network/go-ucanto/core/ipld" ) //go:embed failure.ipldsch @@ -20,23 +20,18 @@ type Failure struct { Stack *string } +func (f *Failure) Build() (ipld.Node, error) { + return ucanipld.WrapWithRecovery(f, typ) +} + var ( - once sync.Once - ts *schema.TypeSystem - err error + typ schema.Type ) -func mustLoadSchema() *schema.TypeSystem { - once.Do(func() { - ts, err = ipld.LoadSchemaBytes(failureSchema) - }) +func init() { + ts, err := ipld.LoadSchemaBytes(failureSchema) if err != nil { - panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + panic(fmt.Errorf("loading failure schema: %w", err)) } - return ts -} - -// returns the failure schematype -func Type() schema.Type { - return mustLoadSchema().TypeByName("Failure") + typ = ts.TypeByName("Failure") } diff --git a/core/result/result.go b/core/result/result.go index ee3d8a1..b4dc9b5 100644 --- a/core/result/result.go +++ b/core/result/result.go @@ -4,45 +4,175 @@ import ( "fmt" "runtime" - "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/pkg/errors" - "github.com/web3-storage/go-ucanto/core/ipld" - "github.com/web3-storage/go-ucanto/core/result/datamodel" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/result/datamodel" ) -// https://github.com/ucan-wg/invocation/#6-result +// Result is a golang compatible generic result type type Result[O any, X any] interface { - Ok() (O, bool) - Error() (X, bool) + isResult(ok O, err X) } -type AnyResult Result[ipld.Node, ipld.Node] +type okResult[O any, X any] struct { + value O +} +type errResult[O any, X any] struct { + value X +} + +func (o *okResult[O, X]) isResult(ok O, err X) {} + +func (e *errResult[O, X]) isResult(ok O, err X) {} + +// MatchResultR3 handles a result with functions returning 3 values +func MatchResultR3[O any, X any, R0, R1, R2 any]( + result Result[O, X], + onOk func(ok O) (R0, R1, R2), + onError func(err X) (R0, R1, R2), +) (R0, R1, R2) { + switch v := result.(type) { + case *okResult[O, X]: + return onOk(v.value) + case *errResult[O, X]: + return onError(v.value) + default: + panic("unexpected result type") + } +} -type anyResult struct { - ok *ipld.Node - err *ipld.Node +// MatchResultR2 handles a result with functions returning two values +func MatchResultR2[O any, X any, R0, R1 any]( + result Result[O, X], + onOk func(ok O) (R0, R1), + onError func(err X) (R0, R1), +) (R0, R1) { + switch v := result.(type) { + case *okResult[O, X]: + return onOk(v.value) + case *errResult[O, X]: + return onError(v.value) + default: + panic("unexpected result type") + } } -func (ar anyResult) Ok() (ipld.Node, bool) { - if ar.ok != nil { - return *ar.ok, true +// MatchResultR1 handles a result with functions returning one value +func MatchResultR1[O any, X any, T0 any]( + result Result[O, X], + onOk func(ok O) T0, + onError func(err X) T0, +) T0 { + switch v := result.(type) { + case *okResult[O, X]: + return onOk(v.value) + case *errResult[O, X]: + return onError(v.value) + default: + panic("unexpected result type") } - return nil, false } -func (ar anyResult) Error() (ipld.Node, bool) { - if ar.err != nil { - return *ar.err, true +// MatchResultR1 handles a result with a functions that has no return value +func MatchResultR0[O any, X any]( + result Result[O, X], + onOk func(ok O), + onError func(err X), +) { + switch v := result.(type) { + case *okResult[O, X]: + onOk(v.value) + case *errResult[O, X]: + onError(v.value) + default: + panic("unexpected result type") } - return nil, false } -func Ok(value ipld.Node) AnyResult { - return anyResult{&value, nil} +// Ok returns a success result type +func Ok[O, X any](value O) Result[O, X] { + return &okResult[O, X]{value} +} + +// Error returns an error result type +func Error[O, X any](value X) Result[O, X] { + return &errResult[O, X]{value} } -func Error(err ipld.Node) AnyResult { - return anyResult{nil, &err} +// MapOk transforms a successful result while leaving an error result unchanged +func MapOk[O, X, O2 any](result Result[O, X], mapFn func(O) O2) Result[O2, X] { + return MapResultR0(result, mapFn, func(err X) X { return err }) +} + +// MapError transforms an error result while leaving a success result unchanged +func MapError[O, X, X2 any](result Result[O, X], mapFn func(X) X2) Result[O, X2] { + return MapResultR0(result, func(ok O) O { return ok }, mapFn) +} + +// MapResultR0 transforms a result -- +// with seperate functions to modify both the success type and error type +func MapResultR0[O, X, O2, X2 any](result Result[O, X], mapOkFn func(O) O2, mapErrFn func(X) X2) Result[O2, X2] { + return MatchResultR1(result, func(ok O) Result[O2, X2] { + return Ok[O2, X2](mapOkFn(ok)) + }, func(err X) Result[O2, X2] { + return Error[O2, X2](mapErrFn(err)) + }) +} + +// MapResultR1 transforms a result -- +// with seperate functions to modify both the success type and error type that also returna one additional value +func MapResultR1[O, X, O2, X2, R1 any](result Result[O, X], mapOkFn func(O) (O2, R1), mapErrFn func(X) (X2, R1)) (Result[O2, X2], R1) { + return MatchResultR2(result, func(ok O) (Result[O2, X2], R1) { + ok2, r1 := mapOkFn(ok) + return Ok[O2, X2](ok2), r1 + }, func(err X) (Result[O2, X2], R1) { + err2, r1 := mapErrFn(err) + return Error[O2, X2](err2), r1 + }) +} + +// And treats a result as a boolean, returning the second result only if the +// the first is succcessful +func And[O, O2, X any](res1 Result[O, X], res2 Result[O2, X]) Result[O2, X] { + return AndThen(res1, func(_ O) Result[O2, X] { return res2 }) +} + +// AndThen takes a result and if it is success type, +// runs an additional function that returns a subsequent result type +func AndThen[O, X, O2 any](result Result[O, X], thenFunc func(O) Result[O2, X]) Result[O2, X] { + return MatchResultR1(result, func(ok O) Result[O2, X] { + return thenFunc(ok) + }, func(err X) Result[O2, X] { + return Error[O2, X](err) + }) +} + +// Or treats a result as a boolean, returning the second result if the first +// result is an error +func Or[O, X, X2 any](res1 Result[O, X], res2 Result[O, X2]) Result[O, X2] { + return OrElse(res1, func(err X) Result[O, X2] { return res2 }) +} + +// OrElse takes a result and if it is an error type, +// runs an additional function that returns a subsequent result type +func OrElse[O, X, X2 any](result Result[O, X], elseFunc func(X) Result[O, X2]) Result[O, X2] { + return MatchResultR1(result, func(ok O) Result[O, X2] { + return Ok[O, X2](ok) + }, func(err X) Result[O, X2] { + return elseFunc(err) + }) +} + +// Wrap wraps a traditional golang pattern for two value functions with the +// second being an error where the zero value indicates absence, converting +// it to a result +func Wrap[O any, X comparable](inner func() (O, X)) Result[O, X] { + o, err := inner() + var nilErr X + if err != nilErr { + return Error[O, X](err) + } + return Ok[O, X](o) } // Named is an error that you can read a name from @@ -58,7 +188,12 @@ type WithStackTrace interface { // IPLDConvertableError is an error with a custom method to convert to an IPLD Node type IPLDConvertableError interface { error - ToIPLD() ipld.Node + ipld.Builder +} + +type Failure interface { + error + Named } type NamedWithStackTrace interface { @@ -93,14 +228,9 @@ func NamedWithCurrentStackTrace(name string) NamedWithStackTrace { return namedWithStackTrace{name, f} } -// Failure generates a Result from a golang error, using: -// 1. a custom conversion to IPLD if present -// 2. the golangs error message plus -// a. a name, if it is a named error -// b. a stack trace, if it has a stack trace -func Failure(err error) AnyResult { +func NewFailure(err error) Result[ipld.Builder, ipld.Builder] { if ipldConvertableError, ok := err.(IPLDConvertableError); ok { - return Error(ipldConvertableError.ToIPLD()) + return Error[ipld.Builder, ipld.Builder](ipldConvertableError) } failure := datamodel.Failure{Message: err.Error()} @@ -112,5 +242,5 @@ func Failure(err error) AnyResult { stack := withStackTrace.Stack() failure.Stack = &stack } - return Error(bindnode.Wrap(&failure, datamodel.Type())) + return Error[ipld.Builder, ipld.Builder](&failure) } diff --git a/core/result/result_test.go b/core/result/result_test.go new file mode 100644 index 0000000..964f746 --- /dev/null +++ b/core/result/result_test.go @@ -0,0 +1,400 @@ +package result_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/storacha-network/go-ucanto/core/result" + "github.com/stretchr/testify/require" +) + +func TestMatchResult(t *testing.T) { + t.Run("MatchResultR0", func(t *testing.T) { + testMatchResultR0(t, "ok (int)", result.Ok[int, any](5), true, false) + testMatchResultR0(t, "ok (string)", result.Ok[string, any]("apple"), true, false) + testMatchResultR0(t, "err (error)", result.Error[int](errors.New("bad")), false, true) + }) + t.Run("MatchResultR1", func(t *testing.T) { + testMatchResultR1(t, "ok (int)", + result.Ok[int, any](5), + func(o int) int { return o * 2 }, + func(x any) int { return 0 }, + 10) + testMatchResultR1(t, "ok (string)", + result.Ok[string, any]("apple"), + func(o string) string { return o + " tree" }, + func(x any) string { return "nothing" }, + "apple tree") + testMatchResultR1(t, "err (error)", + result.Error[int](errors.New("bad")), + func(o int) string { return "" }, + func(x error) string { return x.Error() }, + "bad") + }) + t.Run("MatchResultR2", func(t *testing.T) { + testMatchResultR2(t, "ok (int)", + result.Ok[int, any](5), + func(o int) (int, int) { return o * 2, o * 3 }, + func(x any) (int, int) { return 0, 0 }, + 10, 15) + testMatchResultR2(t, "ok (string)", + result.Ok[string, any]("apple"), + func(o string) (string, int) { return o + " tree", len(o) }, + func(x any) (string, int) { return "nothing", 0 }, + "apple tree", 5) + testMatchResultR2(t, "err (error)", + result.Error[int](errors.New("bad")), + func(o int) (string, error) { return "", nil }, + func(x error) (string, error) { return x.Error(), fmt.Errorf("something: %w", x) }, + "bad", fmt.Errorf("something: %w", errors.New("bad"))) + }) + + t.Run("MatchResultR2", func(t *testing.T) { + testMatchResultR3(t, "ok (int)", + result.Ok[int, any](5), + func(o int) (int, int, int) { return o * 2, o * 3, o * 4 }, + func(x any) (int, int, int) { return 0, 0, 0 }, + 10, 15, 20) + testMatchResultR3(t, "ok (string)", + result.Ok[string, any]("apple"), + func(o string) (string, int, string) { return o + " tree", len(o), o + " cart" }, + func(x any) (string, int, string) { return "nothing", 0, "nothing" }, + "apple tree", 5, "apple cart") + testMatchResultR3(t, "err (error)", + result.Error[int](errors.New("bad")), + func(o int) (string, error, int) { return "", nil, 0 }, + func(x error) (string, error, int) { return x.Error(), fmt.Errorf("something: %w", x), len(x.Error()) }, + "bad", fmt.Errorf("something: %w", errors.New("bad")), 3) + }) +} + +func TestMap(t *testing.T) { + t.Run("MapOk", func(t *testing.T) { + testMapOk(t, "ok (int)", result.Ok[int, error](5), func(o int) int { return o * 2 }, result.Ok[int, error](10)) + testMapOk(t, "ok (string)", + result.Ok[string, error]("apple"), + func(o string) int { return len(o) }, + result.Ok[int, error](5)) + testMapOk(t, "err (int)", + result.Error[int](errors.New("bad")), + func(o int) int { return o * 2 }, + result.Error[int](errors.New("bad"))) + testMapOk(t, "err (string)", + result.Error[string](errors.New("bad")), + func(o string) int { return len(o) }, + result.Error[int](errors.New("bad"))) + }) + t.Run("MapError", func(t *testing.T) { + testMapErr(t, "ok (int)", + result.Ok[int, error](5), + func(e error) error { return fmt.Errorf("something: %w", e) }, + result.Ok[int, error](5)) + testMapErr(t, "ok (string)", + result.Ok[string, error]("apple"), + func(e error) string { return e.Error() }, + result.Ok[string, string]("apple")) + testMapErr(t, "err (int)", + result.Error[int](errors.New("bad")), + func(e error) error { return fmt.Errorf("something: %w", e) }, + result.Error[int](fmt.Errorf("something: %w", errors.New("bad")))) + testMapErr(t, "err (string)", + result.Error[string](errors.New("bad")), + func(e error) string { return e.Error() }, + result.Error[string]("bad")) + }) + t.Run("MapResult", func(t *testing.T) { + testMapResult(t, "ok (int)", + result.Ok[int, error](5), + func(o int) int { return o * 2 }, + func(e error) error { return fmt.Errorf("something: %w", e) }, + result.Ok[int, error](10)) + testMapResult(t, "ok (string)", + result.Ok[string, error]("apple"), + func(o string) int { return len(o) }, + func(e error) string { return e.Error() }, + result.Ok[int, string](5)) + testMapResult(t, "err (int)", + result.Error[int](errors.New("bad")), + func(o int) int { return o * 2 }, + func(e error) error { return fmt.Errorf("something: %w", e) }, + result.Error[int](fmt.Errorf("something: %w", errors.New("bad")))) + testMapResult(t, "err (string)", + result.Error[string](errors.New("bad")), + func(o string) int { return len(o) }, + func(e error) string { return e.Error() }, + result.Error[int]("bad")) + }) + + t.Run("MapResult", func(t *testing.T) { + testMapResultR1(t, "ok (int)", + result.Ok[int, error](5), + func(o int) (int, error) { return o * 2, nil }, + func(e error) (error, error) { return fmt.Errorf("something: %w", e), errors.New("very") }, + result.Ok[int, error](10), nil) + testMapResultR1(t, "ok (string)", + result.Ok[string, error]("apple"), + func(o string) (int, string) { return len(o), o + " tree" }, + func(e error) (string, string) { return e.Error(), fmt.Errorf("something: %w", e).Error() }, + result.Ok[int, string](5), "apple tree") + testMapResultR1(t, "err (int)", + result.Error[int](errors.New("bad")), + func(o int) (int, error) { return o * 2, nil }, + func(e error) (error, error) { return fmt.Errorf("something: %w", e), errors.New("very") }, + result.Error[int](fmt.Errorf("something: %w", errors.New("bad"))), errors.New("very")) + testMapResultR1(t, "err (string)", + result.Error[string](errors.New("bad")), + func(o string) (int, string) { return len(o), o + " tree" }, + func(e error) (string, string) { return e.Error(), fmt.Errorf("something: %w", e).Error() }, + result.Error[int]("bad"), "something: bad") + }) +} + +func TestWrap(t *testing.T) { + require.Equal(t, + result.Ok[int, error](5), + result.Wrap(func() (int, error) { return 5, nil }), + "int (no error)") + require.Equal(t, + result.Error[int](errors.New("bad")), + result.Wrap(func() (int, error) { return 0, errors.New("bad") }), + "string (no error)") + require.Equal(t, + result.Ok[string, error]("apple"), + result.Wrap(func() (string, error) { return "apple", nil }), + "int (no error)") + require.Equal(t, + result.Error[string](errors.New("bad")), + result.Wrap(func() (string, error) { return "", errors.New("bad") }), + "string (error present)") +} + +func TestAndOr(t *testing.T) { + t.Run("And", func(t *testing.T) { + testAnd(t, "O - int, O2 - string, X - error (no error)", + result.Ok[int, error](10), + result.Ok[string, error]("apple"), + result.Ok[string, error]("apple")) + testAnd(t, "O - int, O2 - string, X - error (first error)", + result.Error[int](errors.New("bad")), + result.Ok[string, error]("apple"), + result.Error[string](errors.New("bad"))) + testAnd(t, "O - int, O2 - string, X - error (second error)", + result.Ok[int, error](10), + result.Error[string](errors.New("very bad")), + result.Error[string](errors.New("very bad"))) + testAnd(t, "O - int, O2 - string, X - error (both error)", + result.Error[int](errors.New("bad")), + result.Error[string](errors.New("very bad")), + result.Error[string](errors.New("bad"))) + }) + + t.Run("Or", func(t *testing.T) { + testOr(t, "O - int, X - error, X2 - string (no error)", + result.Ok[int, error](10), + result.Ok[int, string](5), + result.Ok[int, string](10)) + testOr(t, "O - int, X - error, X2 - string (first error)", + result.Error[int](errors.New("bad")), + result.Ok[int, string](5), + result.Ok[int, string](5)) + testOr(t, "O - int, X - error, X2 - string (second error)", + result.Ok[int, error](10), + result.Error[int]("very bad"), + result.Ok[int, string](10)) + testOr(t, "O - int, X - error, X2 - string (both error)", + result.Error[int](errors.New("bad")), + result.Error[int]("very bad"), + result.Error[int]("very bad")) + }) + + t.Run("AndThen", func(t *testing.T) { + testAndThen(t, "O - int, O2 - string, X - error (ok, () -> ok)", + result.Ok[int, error](10), + func(x int) result.Result[string, error] { + return result.Ok[string, error](fmt.Sprintf("%d", x)) + }, + result.Ok[string, error]("10")) + testAndThen(t, "O - int, O2 - string, X - error (err, () -> ok)", + result.Error[int](errors.New("bad")), + func(x int) result.Result[string, error] { + return result.Ok[string, error](fmt.Sprintf("%d", x)) + }, + result.Error[string](errors.New("bad"))) + testAndThen(t, "O - int, O2 - string, X - error (ok, () -> err)", + result.Ok[int, error](10), + func(x int) result.Result[string, error] { + return result.Error[string](fmt.Errorf("%d", x)) + }, + result.Error[string](fmt.Errorf("10"))) + testAndThen(t, "O - int, O2 - string, X - error (err, () -> err)", + result.Error[int](errors.New("bad")), + func(x int) result.Result[string, error] { + return result.Error[string](fmt.Errorf("%d", x)) + }, + result.Error[string](errors.New("bad"))) + }) + t.Run("OrElse", func(t *testing.T) { + testOrElse(t, "O - int, X - error, X2 - string (ok, () -> ok)", + result.Ok[int, error](10), + func(e error) result.Result[int, string] { + return result.Ok[int, string](len(e.Error())) + }, + result.Ok[int, string](10)) + testOrElse(t, "O - int, X - error, X2 - string (err, () -> ok)", + result.Error[int](errors.New("bad")), + func(e error) result.Result[int, string] { + return result.Ok[int, string](len(e.Error())) + }, + result.Ok[int, string](3)) + testOrElse(t, "O - int, X - error, X2 - string (ok, () -> err)", + result.Ok[int, error](10), + func(e error) result.Result[int, string] { + return result.Error[int](e.Error()) + }, + result.Ok[int, string](10)) + testOrElse(t, "O - int, X - error, X2 - string (err, () -> err)", + result.Error[int](errors.New("bad")), + func(e error) result.Result[int, string] { + return result.Error[int](e.Error()) + }, + result.Error[int]("bad")) + }) +} +func testMatchResultR0[X, O any](t *testing.T, testCase string, testResult result.Result[O, X], expOk bool, expErr bool) { + t.Run(testCase, func(t *testing.T) { + var okCalled, errCalled bool + result.MatchResultR0(testResult, func(_ O) { + okCalled = true + }, func(_ X) { + errCalled = true + }) + require.Equal(t, expOk, okCalled) + require.Equal(t, expErr, errCalled) + }) +} + +func testMatchResultR1[X, O, R1 any](t *testing.T, testCase string, testResult result.Result[O, X], onOk func(O) R1, onErr func(X) R1, expR1 R1) { + t.Run(testCase, func(t *testing.T) { + r1 := result.MatchResultR1(testResult, onOk, onErr) + require.Equal(t, expR1, r1) + }) +} + +func testMatchResultR2[X, O, R1, R2 any](t *testing.T, testCase string, testResult result.Result[O, X], onOk func(O) (R1, R2), onErr func(X) (R1, R2), expR1 R1, expR2 R2) { + t.Run(testCase, func(t *testing.T) { + r1, r2 := result.MatchResultR2(testResult, onOk, onErr) + require.Equal(t, expR1, r1) + require.Equal(t, expR2, r2) + }) +} + +func testMatchResultR3[X, O, R1, R2, R3 any]( + t *testing.T, + testCase string, + testResult result.Result[O, X], + onOk func(O) (R1, R2, R3), + onErr func(X) (R1, R2, R3), + expR1 R1, + expR2 R2, + expR3 R3, +) { + t.Run(testCase, func(t *testing.T) { + r1, r2, r3 := result.MatchResultR3(testResult, onOk, onErr) + require.Equal(t, expR1, r1) + require.Equal(t, expR2, r2) + require.Equal(t, expR3, r3) + }) +} + +func testMapOk[O, O2, X any](t *testing.T, + testCase string, + testResult result.Result[O, X], + mapFn func(O) O2, + expResult result.Result[O2, X]) { + t.Run(testCase, func(t *testing.T) { + result := result.MapOk(testResult, mapFn) + require.Equal(t, expResult, result) + }) +} + +func testMapErr[O, X, X2 any](t *testing.T, + testCase string, + testResult result.Result[O, X], + mapFn func(X) X2, + expResult result.Result[O, X2]) { + t.Run(testCase, func(t *testing.T) { + result := result.MapError(testResult, mapFn) + require.Equal(t, expResult, result) + }) +} + +func testMapResult[O, O2, X, X2 any](t *testing.T, + testCase string, + testResult result.Result[O, X], + mapOk func(O) O2, + mapErr func(X) X2, + expResult result.Result[O2, X2]) { + t.Run(testCase, func(t *testing.T) { + result := result.MapResultR0(testResult, mapOk, mapErr) + require.Equal(t, expResult, result) + }) +} + +func testMapResultR1[O, O2, X, X2, R1 any](t *testing.T, + testCase string, + testResult result.Result[O, X], + mapOk func(O) (O2, R1), + mapErr func(X) (X2, R1), + expResult result.Result[O2, X2], + expR1 R1) { + t.Run(testCase, func(t *testing.T) { + result, r1 := result.MapResultR1(testResult, mapOk, mapErr) + require.Equal(t, expResult, result) + require.Equal(t, expR1, r1) + }) +} + +func testAnd[O, O2, X any]( + t *testing.T, + testCase string, + r1 result.Result[O, X], + r2 result.Result[O2, X], + expResult result.Result[O2, X]) { + t.Run(testCase, func(t *testing.T) { + require.Equal(t, expResult, result.And(r1, r2)) + }) +} + +func testOr[O, X, X2 any]( + t *testing.T, + testCase string, + r1 result.Result[O, X], + r2 result.Result[O, X2], + expResult result.Result[O, X2]) { + t.Run(testCase, func(t *testing.T) { + require.Equal(t, expResult, result.Or(r1, r2)) + }) +} + +func testAndThen[O, O2, X any]( + t *testing.T, + testCase string, + r1 result.Result[O, X], + after func(O) result.Result[O2, X], + expResult result.Result[O2, X]) { + t.Run(testCase, func(t *testing.T) { + require.Equal(t, expResult, result.AndThen(r1, after)) + }) +} + +func testOrElse[O, X, X2 any]( + t *testing.T, + testCase string, + r1 result.Result[O, X], + after func(X) result.Result[O, X2], + expResult result.Result[O, X2]) { + t.Run(testCase, func(t *testing.T) { + require.Equal(t, expResult, result.OrElse(r1, after)) + }) +} diff --git a/go.mod b/go.mod index 8979b93..d705408 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/web3-storage/go-ucanto +module github.com/storacha-network/go-ucanto go 1.21 @@ -11,9 +11,11 @@ require ( github.com/multiformats/go-multihash v0.2.3 github.com/multiformats/go-varint v0.0.7 github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.8.4 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -43,6 +45,7 @@ require ( github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/whyrusleeping/cbor-gen v0.0.0-20230818171029-f91ae536ca25 // indirect @@ -55,5 +58,6 @@ require ( golang.org/x/sys v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.1.7 // indirect ) diff --git a/principal/ed25519/signer/signer.go b/principal/ed25519/signer/signer.go index e234327..c5fd065 100644 --- a/principal/ed25519/signer/signer.go +++ b/principal/ed25519/signer/signer.go @@ -8,10 +8,10 @@ import ( "github.com/multiformats/go-multibase" "github.com/multiformats/go-varint" - "github.com/web3-storage/go-ucanto/did" - "github.com/web3-storage/go-ucanto/principal" - "github.com/web3-storage/go-ucanto/principal/ed25519/verifier" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/principal" + "github.com/storacha-network/go-ucanto/principal/ed25519/verifier" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" ) const Code = 0x1300 @@ -49,6 +49,10 @@ func Parse(str string) (principal.Signer, error) { return Decode(bytes) } +func Format(signer principal.Signer) (string, error) { + return multibase.Encode(multibase.Base64pad, signer.Encode()) +} + func Decode(b []byte) (principal.Signer, error) { if len(b) != size { return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), size) diff --git a/principal/ed25519/signer/signer_test.go b/principal/ed25519/signer/signer_test.go index b5cae73..8063622 100644 --- a/principal/ed25519/signer/signer_test.go +++ b/principal/ed25519/signer/signer_test.go @@ -25,6 +25,33 @@ func TestGenerateEncodeDecode(t *testing.T) { } } +func TestGenerateFormatParse(t *testing.T) { + s0, err := Generate() + if err != nil { + t.Fatalf("generating Ed25519 key: %v", err) + } + + fmt.Println(s0.DID().String()) + + str, err := Format(s0) + if err != nil { + t.Fatalf("formatting Ed25519 key: %v", err) + } + + fmt.Println(str) + + s1, err := Parse(str) + if err != nil { + t.Fatalf("parsing Ed25519 key: %v", err) + } + + fmt.Println(s1.DID().String()) + + if s0.DID().String() != s1.DID().String() { + t.Fatalf("public key mismatch: %s != %s", s0.DID().String(), s1.DID().String()) + } +} + func TestVerify(t *testing.T) { s0, err := Generate() if err != nil { diff --git a/principal/ed25519/verifier/verifier.go b/principal/ed25519/verifier/verifier.go index 2507886..69f4aed 100644 --- a/principal/ed25519/verifier/verifier.go +++ b/principal/ed25519/verifier/verifier.go @@ -6,9 +6,9 @@ import ( "fmt" "github.com/multiformats/go-varint" - "github.com/web3-storage/go-ucanto/did" - "github.com/web3-storage/go-ucanto/principal" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/principal" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" ) const Code = 0xed diff --git a/principal/lib.go b/principal/lib.go index f8b3faf..ce13c9f 100644 --- a/principal/lib.go +++ b/principal/lib.go @@ -1,7 +1,7 @@ package principal import ( - "github.com/web3-storage/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/ucan" ) type Signer interface { diff --git a/server/datamodel/errors.go b/server/datamodel/errors.go new file mode 100644 index 0000000..6e4a205 --- /dev/null +++ b/server/datamodel/errors.go @@ -0,0 +1,75 @@ +package datamodel + +import ( + // for go:embed + _ "embed" + "fmt" + + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/schema" +) + +//go:embed errors.ipldsch +var errorsch []byte + +var ( + errorTypeSystem *schema.TypeSystem +) + +func init() { + ts, err := ipld.LoadSchemaBytes(errorsch) + if err != nil { + panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + } + errorTypeSystem = ts +} + +func Schema() []byte { + return errorsch +} + +func HandlerExecutionErrorType() schema.Type { + return errorTypeSystem.TypeByName("HandlerExecutionError") +} + +type FailureModel struct { + Name *string + Message string + Stack *string +} + +type CapabilityModel struct { + Can string + With string +} + +type HandlerExecutionErrorModel struct { + Error bool + Name *string + Message string + Stack *string + Cause FailureModel + Capability CapabilityModel +} + +func InvocationCapabilityErrorType() schema.Type { + return errorTypeSystem.TypeByName("HandlerExecutionError") +} + +type InvocationCapabilityErrorModel struct { + Error bool + Name *string + Message string + Capabilities []CapabilityModel +} + +func HandlerNotFoundErrorType() schema.Type { + return errorTypeSystem.TypeByName("HandlerNotFoundError") +} + +type HandlerNotFoundErrorModel struct { + Error bool + Name *string + Message string + Capability CapabilityModel +} diff --git a/server/datamodel/errors.ipldsch b/server/datamodel/errors.ipldsch new file mode 100644 index 0000000..8078daf --- /dev/null +++ b/server/datamodel/errors.ipldsch @@ -0,0 +1,33 @@ +type HandlerExecutionError struct { + error Bool + name optional String + message String + stack optional String + cause Failure + capability Capability +} + +type Capability struct { + can String + with String +} + +type Failure struct { + name optional String + message String + stack optional String +} + +type InvocationCapabilityError struct { + name optional String + message String + error Bool + capabilities [Capability] +} + +type HandlerNotFoundError struct { + error Bool + name optional String + message String + capability Capability +} diff --git a/server/error.go b/server/error.go new file mode 100644 index 0000000..8925bba --- /dev/null +++ b/server/error.go @@ -0,0 +1,175 @@ +package server + +import ( + "fmt" + + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/result" + sdm "github.com/storacha-network/go-ucanto/server/datamodel" + "github.com/storacha-network/go-ucanto/ucan" +) + +type HandlerNotFoundError[Caveats any] interface { + result.Failure + Capability() ucan.Capability[Caveats] +} + +type handlerNotFoundError[Caveats any] struct { + capability ucan.Capability[Caveats] +} + +func (h *handlerNotFoundError[C]) Capability() ucan.Capability[C] { + return h.capability +} + +func (h *handlerNotFoundError[C]) Error() string { + return fmt.Sprintf("service does not implement {can: \"%s\"} handler", h.capability.Can()) +} + +func (h *handlerNotFoundError[C]) Name() string { + return "HandlerNotFoundError" +} + +func (h *handlerNotFoundError[C]) Build() (ipld.Node, error) { + name := h.Name() + + mdl := sdm.HandlerNotFoundErrorModel{ + Error: true, + Name: &name, + Message: h.Error(), + Capability: sdm.CapabilityModel{ + Can: h.capability.Can(), + With: h.capability.With(), + }, + } + return ipld.WrapWithRecovery(&mdl, sdm.HandlerNotFoundErrorType()) +} + +var _ HandlerNotFoundError[any] = (*handlerNotFoundError[any])(nil) +var _ ipld.Builder = (*handlerNotFoundError[any])(nil) + +func NewHandlerNotFoundError[Caveats any](capability ucan.Capability[Caveats]) *handlerNotFoundError[Caveats] { + return &handlerNotFoundError[Caveats]{capability} +} + +type HandlerExecutionError[Caveats any] interface { + result.Failure + result.WithStackTrace + Cause() error + Capability() ucan.Capability[Caveats] +} + +type handlerExecutionError[Caveats any] struct { + cause error + capability ucan.Capability[Caveats] +} + +func (h *handlerExecutionError[C]) Capability() ucan.Capability[C] { + return h.capability +} + +func (h *handlerExecutionError[C]) Cause() error { + return h.cause +} + +func (h *handlerExecutionError[C]) Error() string { + return fmt.Sprintf("service handler {can: \"%s\"} error: %s", h.capability.Can(), h.cause.Error()) +} + +func (h *handlerExecutionError[C]) Name() string { + return "HandlerExecutionError" +} + +func (h *handlerExecutionError[C]) Stack() string { + var stack string + if serr, ok := h.cause.(result.WithStackTrace); ok { + stack = serr.Stack() + } + return stack +} + +func (h *handlerExecutionError[C]) Build() (ipld.Node, error) { + name := h.Name() + stack := h.Stack() + + var cname string + if ncause, ok := h.cause.(result.Named); ok { + cname = ncause.Name() + } + + var cstack string + if scause, ok := h.cause.(result.WithStackTrace); ok { + cstack = scause.Stack() + } + + mdl := sdm.HandlerExecutionErrorModel{ + Error: true, + Name: &name, + Message: h.Error(), + Stack: &stack, + Cause: sdm.FailureModel{ + Name: &cname, + Message: h.cause.Error(), + Stack: &cstack, + }, + Capability: sdm.CapabilityModel{ + Can: h.capability.Can(), + With: h.capability.With(), + }, + } + return ipld.WrapWithRecovery(&mdl, sdm.HandlerExecutionErrorType()) +} + +var _ HandlerExecutionError[any] = (*handlerExecutionError[any])(nil) +var _ ipld.Builder = (*handlerExecutionError[any])(nil) + +func NewHandlerExecutionError[Caveats any](cause error, capability ucan.Capability[Caveats]) *handlerExecutionError[Caveats] { + return &handlerExecutionError[Caveats]{cause, capability} +} + +type InvocationCapabilityError interface { + result.Failure + Capabilities() []ucan.Capability[any] +} + +type invocationCapabilityError struct { + capabilities []ucan.Capability[any] +} + +func (i *invocationCapabilityError) Capabilities() []ucan.Capability[any] { + return i.capabilities +} + +func (i *invocationCapabilityError) Error() string { + return "Invocation is required to have a single capability." +} + +func (i *invocationCapabilityError) Name() string { + return "InvocationCapabilityError" +} + +func (i *invocationCapabilityError) Build() (ipld.Node, error) { + name := i.Name() + var capmdls []sdm.CapabilityModel + for _, cap := range i.Capabilities() { + capmdls = append(capmdls, sdm.CapabilityModel{ + Can: cap.Can(), + With: cap.With(), + }) + } + + mdl := sdm.InvocationCapabilityErrorModel{ + Error: true, + Name: &name, + Message: i.Error(), + Capabilities: capmdls, + } + return ipld.WrapWithRecovery(&mdl, sdm.InvocationCapabilityErrorType()) +} + +var _ InvocationCapabilityError = (*invocationCapabilityError)(nil) +var _ ipld.Builder = (*invocationCapabilityError)(nil) + +func NewInvocationCapabilityError(capabilities []ucan.Capability[any]) *invocationCapabilityError { + return &invocationCapabilityError{capabilities} +} diff --git a/server/handler.go b/server/handler.go new file mode 100644 index 0000000..73e3c48 --- /dev/null +++ b/server/handler.go @@ -0,0 +1,20 @@ +package server + +import ( + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/server/transaction" + "github.com/storacha-network/go-ucanto/ucan" +) + +type HandlerFunc[N any, C ucan.Capability[N], O, X ipld.Builder] func(capability C, invocation invocation.Invocation, context InvocationContext) (transaction.Transaction[O, X], error) + +// Provide is used to define given capability provider. It decorates the passed +// handler and takes care of UCAN validation. It only calls the handler +// when validation succeeds. +func Provide[N any, C ucan.Capability[N], O, X ipld.Builder](capability C, handler HandlerFunc[N, C, O, X]) ServiceMethod[O, X] { + return func(invocation invocation.Invocation, context InvocationContext) (transaction.Transaction[O, X], error) { + // TODO: validation + return handler(capability, invocation, context) + } +} diff --git a/server/options.go b/server/options.go new file mode 100644 index 0000000..95ea49a --- /dev/null +++ b/server/options.go @@ -0,0 +1,79 @@ +package server + +import ( + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/result" + "github.com/storacha-network/go-ucanto/server/transaction" + "github.com/storacha-network/go-ucanto/transport" +) + +// Option is an option configuring a ucanto server. +type Option func(cfg *srvConfig) error + +type srvConfig struct { + codec transport.InboundCodec + service map[string]ServiceMethod[ipld.Builder, ipld.Builder] + validateAuthorization AuthorizationValidatorFunc + canIssue CanIssueFunc + catch ErrorHandlerFunc +} + +func WithServiceMethod[O, X ipld.Builder](can string, handleFunc ServiceMethod[O, X]) Option { + return func(cfg *srvConfig) error { + cfg.service[can] = func(input invocation.Invocation, context InvocationContext) (transaction.Transaction[ipld.Builder, ipld.Builder], error) { + tx, err := handleFunc(input, context) + if err != nil { + return nil, err + } + out := result.MapResultR0( + tx.Out(), + func(o O) ipld.Builder { return o }, + func(x X) ipld.Builder { return x }, + ) + var opts []transaction.Option + if tx.Fx() != nil { + opts = append(opts, transaction.WithForks(tx.Fx().Fork()), transaction.WithJoin(tx.Fx().Join())) + } + return transaction.NewTransaction(out, opts...), nil + } + return nil + } +} + +// WithInboundCodec configures the codec used to decode requests and encode +// responses. +func WithInboundCodec(codec transport.InboundCodec) Option { + return func(cfg *srvConfig) error { + cfg.codec = codec + return nil + } +} + +// WithAuthValidator configures the authorization validator function. The +// primary purpose of the validator is to allow checking UCANs for revocation. +func WithAuthValidator(fn AuthorizationValidatorFunc) Option { + return func(cfg *srvConfig) error { + cfg.validateAuthorization = fn + return nil + } +} + +// WithErrorHandler configures a function to be called when errors occur during +// execution of a handler. +func WithErrorHandler(fn ErrorHandlerFunc) Option { + return func(cfg *srvConfig) error { + cfg.catch = fn + return nil + } +} + +// WithCanIssue configures a function that determines whether a given capability +// can be issued by a given DID or whether it needs to be delegated to the +// issuer. +func WithCanIssue(fn CanIssueFunc) Option { + return func(cfg *srvConfig) error { + cfg.canIssue = fn + return nil + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..c4d84a9 --- /dev/null +++ b/server/server.go @@ -0,0 +1,297 @@ +package server + +import ( + "fmt" + "net/http" + "os" + "strings" + "sync" + + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/invocation/ran" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/message" + "github.com/storacha-network/go-ucanto/core/receipt" + "github.com/storacha-network/go-ucanto/core/result" + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/principal" + "github.com/storacha-network/go-ucanto/principal/ed25519/verifier" + "github.com/storacha-network/go-ucanto/server/transaction" + "github.com/storacha-network/go-ucanto/transport" + "github.com/storacha-network/go-ucanto/transport/car" + thttp "github.com/storacha-network/go-ucanto/transport/http" + "github.com/storacha-network/go-ucanto/ucan" + "github.com/storacha-network/go-ucanto/validator" +) + +// PrincipalParser provides verifier instances that can validate UCANs issued +// by a given principal. +type PrincipalParser interface { + Parse(str string) (principal.Verifier, error) +} + +// CanIssue informs validator whether given capability can be issued by a +// given DID or whether it needs to be delegated to the issuer. +type CanIssueFunc func(capability ucan.Capability[any], issuer did.DID) bool + +// InvocationContext is the context provided to service methods. +type InvocationContext interface { + // ID is the DID of the service the invocation was sent to. + ID() principal.Signer + Principal() PrincipalParser + // CanIssue informs validator whether given capability can be issued by a + // given DID or whether it needs to be delegated to the issuer. + CanIssue(capability ucan.Capability[any], issuer did.DID) bool + // ValidateAuthorization validates the passed authorization and returns + // a result indicating validity. The primary purpose is to check for + // revocation. + ValidateAuthorization(auth validator.Authorization) result.Failure +} + +// ServiceMethod is an invocation handler. +type ServiceMethod[O, X ipld.Builder] func(input invocation.Invocation, context InvocationContext) (transaction.Transaction[O, X], error) + +// Service is a mapping of service names to handlers, used to define a +// service implementation. +type Service = map[string]ServiceMethod[ipld.Builder, ipld.Builder] + +type ServiceInvocation = invocation.IssuedInvocation + +type Server interface { + // ID is the DID which will be used to verify that received invocation + // audience matches it. + ID() principal.Signer + Codec() transport.InboundCodec + Context() InvocationContext + // Service is the actual service providing capability handlers. + Service() Service + Catch(err HandlerExecutionError[any]) +} + +// Server is a materialized service that is configured to use a specific +// transport channel. It has a invocation context which contains the DID of the +// service itself, among other things. +type ServerView interface { + Server + transport.Channel + // Run executes a single invocation and returns a receipt. + Run(invocation ServiceInvocation) (receipt.AnyReceipt, error) +} + +// AuthorizationValidatorFunc validates the passed authorization and returns +// a result indicating validity. The primary purpose is to check for revocation. +type AuthorizationValidatorFunc func(auth validator.Authorization) result.Failure + +// ErrorHandlerFunc allows non-result errors generated during handler execution +// to be logged. +type ErrorHandlerFunc func(err HandlerExecutionError[any]) + +func NewServer(id principal.Signer, options ...Option) (ServerView, error) { + cfg := srvConfig{service: Service{}} + for _, opt := range options { + if err := opt(&cfg); err != nil { + return nil, err + } + } + + codec := cfg.codec + if codec == nil { + codec = car.NewCARInboundCodec() + } + + canIssue := cfg.canIssue + if canIssue == nil { + canIssue = validator.IsSelfIssued + } + + catch := cfg.catch + if catch == nil { + catch = func(err HandlerExecutionError[any]) { + fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) + } + } + + validateAuthorization := cfg.validateAuthorization + if validateAuthorization == nil { + validateAuthorization = func(auth validator.Authorization) result.Failure { + return nil + } + } + + ctx := &context{id: id, canIssue: canIssue, principal: &principalParser{}} + svr := &server{id: id, service: cfg.service, context: ctx, codec: codec, catch: catch} + return svr, nil +} + +type principalParser struct{} + +func (p *principalParser) Parse(str string) (principal.Verifier, error) { + return verifier.Parse(str) +} + +var _ PrincipalParser = (*principalParser)(nil) + +type context struct { + id principal.Signer + canIssue CanIssueFunc + principal PrincipalParser + validateAuthorization AuthorizationValidatorFunc +} + +func (ctx *context) ID() principal.Signer { + return ctx.id +} + +func (ctx *context) CanIssue(capability ucan.Capability[any], issuer did.DID) bool { + return ctx.canIssue(capability, issuer) +} + +func (ctx *context) Principal() PrincipalParser { + return ctx.principal +} + +func (ctx *context) ValidateAuthorization(auth validator.Authorization) result.Failure { + return ctx.validateAuthorization(auth) +} + +var _ InvocationContext = (*context)(nil) + +type server struct { + id principal.Signer + service Service + context InvocationContext + codec transport.InboundCodec + catch ErrorHandlerFunc +} + +func (srv *server) ID() principal.Signer { + return srv.id +} + +func (srv *server) Service() Service { + return srv.service +} + +func (srv *server) Context() InvocationContext { + return srv.context +} + +func (srv *server) Codec() transport.InboundCodec { + return srv.codec +} + +func (srv *server) Request(request transport.HTTPRequest) (transport.HTTPResponse, error) { + return Handle(srv, request) +} + +func (srv *server) Run(invocation ServiceInvocation) (receipt.AnyReceipt, error) { + return Run(srv, invocation) +} + +func (srv *server) Catch(err HandlerExecutionError[any]) { + srv.catch(err) +} + +var _ transport.Channel = (*server)(nil) +var _ ServerView = (*server)(nil) + +func Handle(server Server, request transport.HTTPRequest) (transport.HTTPResponse, error) { + selection, aerr := server.Codec().Accept(request) + if aerr != nil { + return thttp.NewHTTPResponse(aerr.Status(), strings.NewReader(aerr.Error()), aerr.Headers()), nil + } + + msg, err := selection.Decoder().Decode(request) + if err != nil { + return thttp.NewHTTPResponse(http.StatusBadRequest, strings.NewReader("The server failed to decode the request payload. Please format the payload according to the specified media type."), nil), nil + } + + result, err := Execute(server, msg) + if err != nil { + return nil, err + } + + return selection.Encoder().Encode(result) +} + +func Execute(server Server, msg message.AgentMessage) (message.AgentMessage, error) { + br, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(msg.Blocks())) + if err != nil { + return nil, err + } + + var invs []invocation.Invocation + for _, invlnk := range msg.Invocations() { + inv, err := invocation.NewInvocationView(invlnk, br) + if err != nil { + return nil, err + } + invs = append(invs, inv) + } + + var rcpts []receipt.AnyReceipt + var rerr error + var wg sync.WaitGroup + var lock sync.RWMutex + for _, inv := range invs { + wg.Add(1) + go func(inv invocation.Invocation) { + defer wg.Done() + rcpt, err := Run(server, inv) + if err != nil { + rerr = err + return + } + + lock.Lock() + defer lock.Unlock() + rcpts = append(rcpts, rcpt) + }(inv) + } + wg.Wait() + + if rerr != nil { + return nil, rerr + } + + return message.Build(nil, rcpts) +} + +func Run(server Server, invocation ServiceInvocation) (receipt.AnyReceipt, error) { + caps := invocation.Capabilities() + // Invocation needs to have one single capability + if len(caps) != 1 { + err := NewInvocationCapabilityError(invocation.Capabilities()) + return receipt.Issue(server.ID(), result.NewFailure(err), ran.FromInvocation(invocation)) + } + + cap := caps[0] + handle, ok := server.Service()[cap.Can()] + if !ok { + err := NewHandlerNotFoundError(cap) + return receipt.Issue(server.ID(), result.NewFailure(err), ran.FromInvocation(invocation)) + } + + outcome, err := handle(invocation, server.Context()) + if err != nil { + herr := NewHandlerExecutionError(err, cap) + server.Catch(herr) + return receipt.Issue(server.ID(), result.NewFailure(herr), ran.FromInvocation(invocation)) + } + + fx := outcome.Fx() + var opts []receipt.Option + if fx != nil { + opts = append(opts, receipt.WithJoin(fx.Join()), receipt.WithForks(fx.Fork())) + } + + rcpt, err := receipt.Issue(server.ID(), outcome.Out(), ran.FromInvocation(invocation), opts...) + if err != nil { + herr := NewHandlerExecutionError(err, cap) + server.Catch(herr) + return receipt.Issue(server.ID(), result.NewFailure(herr), ran.FromInvocation(invocation)) + } + + return rcpt, nil +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..c1fea38 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,189 @@ +package server + +import ( + "bytes" + "fmt" + "testing" + + "github.com/ipfs/go-cid" + cidlink "github.com/ipld/go-ipld-prime/linking/cid" + "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/storacha-network/go-ucanto/client" + "github.com/storacha-network/go-ucanto/core/invocation" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/receipt" + "github.com/storacha-network/go-ucanto/core/result" + "github.com/storacha-network/go-ucanto/principal/ed25519/signer" + sdm "github.com/storacha-network/go-ucanto/server/datamodel" + "github.com/storacha-network/go-ucanto/server/transaction" + "github.com/storacha-network/go-ucanto/testing/helpers" + "github.com/storacha-network/go-ucanto/ucan" +) + +type uploadAddCaveats struct { + Root ipld.Link +} + +func (c *uploadAddCaveats) Build() (map[string]ipld.Node, error) { + data := map[string]ipld.Node{} + b := basicnode.Prototype.Link.NewBuilder() + err := b.AssignLink(c.Root) + if err != nil { + return nil, err + } + data["root"] = b.Build() + return data, nil +} + +type uploadAddSuccess struct { + Status string +} + +func (ok *uploadAddSuccess) Build() (ipld.Node, error) { + np := basicnode.Prototype.Any + nb := np.NewBuilder() + ma, _ := nb.BeginMap(1) + ma.AssembleKey().AssignString("status") + ma.AssembleValue().AssignString(ok.Status) + ma.Finish() + return nb.Build(), nil +} + +func TestHandlerNotFound(t *testing.T) { + service := helpers.Must(signer.Generate()) + alice := helpers.Must(signer.Generate()) + space := helpers.Must(signer.Generate()) + + server := helpers.Must(NewServer(service)) + conn := helpers.Must(client.NewConnection(service, server)) + + rt := cidlink.Link{Cid: cid.MustParse("bafkreiem4twkqzsq2aj4shbycd4yvoj2cx72vezicletlhi7dijjciqpui")} + capability := ucan.NewCapability( + "upload/add", + space.DID().String(), + ucan.CaveatBuilder(&uploadAddCaveats{Root: rt}), + ) + + invs := []invocation.Invocation{helpers.Must(invocation.Invoke(alice, service, capability))} + resp := helpers.Must(client.Execute(invs, conn)) + rcptlnk, ok := resp.Get(invs[0].Link()) + if !ok { + t.Fatalf("missing receipt for invocation: %s", invs[0].Link()) + } + + rcptsch := bytes.Join([][]byte{sdm.Schema(), []byte(` + type Result union { + | Any "ok" + | HandlerNotFoundError "error" + } representation keyed + `)}, []byte("\n")) + + reader := helpers.Must(receipt.NewReceiptReader[ipld.Node, sdm.HandlerNotFoundErrorModel](rcptsch)) + rcpt := helpers.Must(reader.Read(rcptlnk, resp.Blocks())) + + result.MatchResultR0(rcpt.Out(), func(ipld.Node) { + t.Fatalf("expected error: %s", invs[0].Link()) + }, func(rerr sdm.HandlerNotFoundErrorModel) { + fmt.Printf("%s %+v\n", *rerr.Name, rerr) + if *rerr.Name != "HandlerNotFoundError" { + t.Fatalf("unexpected error name: %s", *rerr.Name) + } + }) +} + +func TestSimpleHandler(t *testing.T) { + service := helpers.Must(signer.Generate()) + alice := helpers.Must(signer.Generate()) + space := helpers.Must(signer.Generate()) + + rt := cidlink.Link{Cid: cid.MustParse("bafkreiem4twkqzsq2aj4shbycd4yvoj2cx72vezicletlhi7dijjciqpui")} + nb := uploadAddCaveats{Root: rt} + // TODO: this should be a descriptor not an instance + cap := ucan.NewCapability("upload/add", space.DID().String(), &nb) + + server := helpers.Must(NewServer( + service, + WithServiceMethod("upload/add", Provide(cap, func(cap ucan.Capability[*uploadAddCaveats], inv invocation.Invocation, ctx InvocationContext) (transaction.Transaction[*uploadAddSuccess, ipld.Builder], error) { + r := result.Ok[*uploadAddSuccess, ipld.Builder](&uploadAddSuccess{Status: "done"}) + return transaction.NewTransaction(r), nil + })), + )) + + conn := helpers.Must(client.NewConnection(service, server)) + invs := []invocation.Invocation{helpers.Must(invocation.Invoke(alice, service, cap))} + resp := helpers.Must(client.Execute(invs, conn)) + + // get the receipt link for the invocation from the response + rcptlnk, ok := resp.Get(invs[0].Link()) + if !ok { + t.Fatalf("missing receipt for invocation: %s", invs[0].Link()) + } + + rcptsch := bytes.Join([][]byte{sdm.Schema(), []byte(` + type Result union { + | UploadAddSuccess "ok" + | Any "error" + } representation keyed + + type UploadAddSuccess struct { + status String + } + `)}, []byte("\n")) + + reader := helpers.Must(receipt.NewReceiptReader[*uploadAddSuccess, ipld.Node](rcptsch)) + rcpt := helpers.Must(reader.Read(rcptlnk, resp.Blocks())) + + result.MatchResultR0(rcpt.Out(), func(ok *uploadAddSuccess) { + fmt.Printf("%+v\n", ok) + if ok.Status != "done" { + t.Fatalf("unexpected status: %s", ok.Status) + } + }, func(rerr ipld.Node) { + t.Fatalf("unexpected error: %+v", rerr) + }) +} + +func TestHandlerExecutionError(t *testing.T) { + service := helpers.Must(signer.Generate()) + alice := helpers.Must(signer.Generate()) + space := helpers.Must(signer.Generate()) + + rt := cidlink.Link{Cid: cid.MustParse("bafkreiem4twkqzsq2aj4shbycd4yvoj2cx72vezicletlhi7dijjciqpui")} + nb := uploadAddCaveats{Root: rt} + // TODO: this should be a descriptor not an instance + cap := ucan.NewCapability("upload/add", space.DID().String(), &nb) + + server := helpers.Must(NewServer( + service, + WithServiceMethod("upload/add", Provide(cap, func(cap ucan.Capability[*uploadAddCaveats], inv invocation.Invocation, ctx InvocationContext) (transaction.Transaction[*uploadAddSuccess, ipld.Builder], error) { + return nil, fmt.Errorf("test error") + })), + )) + conn := helpers.Must(client.NewConnection(service, server)) + + invs := []invocation.Invocation{helpers.Must(invocation.Invoke(alice, service, cap))} + resp := helpers.Must(client.Execute(invs, conn)) + rcptlnk, ok := resp.Get(invs[0].Link()) + if !ok { + t.Fatalf("missing receipt for invocation: %s", invs[0].Link()) + } + + rcptsch := bytes.Join([][]byte{sdm.Schema(), []byte(` + type Result union { + | Any "ok" + | HandlerExecutionError "error" + } representation keyed + `)}, []byte("\n")) + + reader := helpers.Must(receipt.NewReceiptReader[ipld.Node, sdm.HandlerExecutionErrorModel](rcptsch)) + rcpt := helpers.Must(reader.Read(rcptlnk, resp.Blocks())) + + result.MatchResultR0(rcpt.Out(), func(ipld.Node) { + t.Fatalf("expected error: %s", invs[0].Link()) + }, func(rerr sdm.HandlerExecutionErrorModel) { + fmt.Printf("%s %+v\n", *rerr.Name, rerr) + if *rerr.Name != "HandlerExecutionError" { + t.Fatalf("unexpected error name: %s", *rerr.Name) + } + }) +} diff --git a/server/transaction/transaction.go b/server/transaction/transaction.go new file mode 100644 index 0000000..9eda9f8 --- /dev/null +++ b/server/transaction/transaction.go @@ -0,0 +1,83 @@ +package transaction + +import ( + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/receipt" + "github.com/storacha-network/go-ucanto/core/result" +) + +// Transaction defines a result & effect pair, used by provider that wishes to +// return results that have effects. +type Transaction[O, X any] interface { + Out() result.Result[O, X] + Fx() receipt.Effects +} + +type transaction[O, X any] struct { + out result.Result[O, X] + fx receipt.Effects +} + +func (t *transaction[O, X]) Out() result.Result[O, X] { + return t.out +} + +func (t *transaction[O, X]) Fx() receipt.Effects { + return t.fx +} + +var _ Transaction[any, any] = (*transaction[any, any])(nil) + +type effects struct { + fork []ipld.Link + join ipld.Link +} + +func (fx *effects) Fork() []ipld.Link { + return fx.fork +} + +func (fx *effects) Join() ipld.Link { + return fx.join +} + +var _ receipt.Effects = (*effects)(nil) + +// Option is an option configuring a transaction. +type Option func(cfg *txConfig) + +type txConfig struct { + fork []ipld.Link + join ipld.Link +} + +// WithForks configures the forks for the receipt. +func WithForks(fork []ipld.Link) Option { + return func(cfg *txConfig) { + cfg.fork = fork + } +} + +// WithJoin configures the join for the receipt. +func WithJoin(join ipld.Link) Option { + return func(cfg *txConfig) { + cfg.join = join + } +} + +func NewTransaction[O, X any](result result.Result[O, X], options ...Option) Transaction[O, X] { + cfg := txConfig{} + for _, opt := range options { + opt(&cfg) + } + + fx := effects{} + if len(cfg.fork) > 0 { + fx.fork = cfg.fork + } + if cfg.join != nil { + fx.join = cfg.join + } + + return &transaction[O, X]{out: result, fx: &fx} +} diff --git a/testing/helpers/helpers.go b/testing/helpers/helpers.go new file mode 100644 index 0000000..99cf62f --- /dev/null +++ b/testing/helpers/helpers.go @@ -0,0 +1,10 @@ +package helpers + +// Must takes return values from a function and returns the non-error one. If +// the error value is non-nil then it panics. +func Must[T any](val T, err error) T { + if err != nil { + panic(err) + } + return val +} diff --git a/transport/car/codec.go b/transport/car/codec.go index 17925de..80f09fa 100644 --- a/transport/car/codec.go +++ b/transport/car/codec.go @@ -1,10 +1,14 @@ package car import ( - "github.com/web3-storage/go-ucanto/core/message" - "github.com/web3-storage/go-ucanto/transport" - "github.com/web3-storage/go-ucanto/transport/car/request" - "github.com/web3-storage/go-ucanto/transport/car/response" + "net/http" + "strings" + + "github.com/storacha-network/go-ucanto/core/message" + "github.com/storacha-network/go-ucanto/transport" + "github.com/storacha-network/go-ucanto/transport/car/request" + "github.com/storacha-network/go-ucanto/transport/car/response" + thttp "github.com/storacha-network/go-ucanto/transport/http" ) type carOutbound struct{} @@ -17,6 +21,67 @@ func (oc *carOutbound) Decode(res transport.HTTPResponse) (message.AgentMessage, return response.Decode(res) } +var _ transport.OutboundCodec = (*carOutbound)(nil) + func NewCAROutboundCodec() transport.OutboundCodec { return &carOutbound{} } + +type carInboundAcceptCodec struct{} + +func (cic *carInboundAcceptCodec) Encoder() transport.ResponseEncoder { + return cic +} + +func (cic *carInboundAcceptCodec) Decoder() transport.RequestDecoder { + return cic +} + +func (cic *carInboundAcceptCodec) Encode(msg message.AgentMessage) (transport.HTTPResponse, error) { + return response.Encode(msg) +} + +func (cic *carInboundAcceptCodec) Decode(req transport.HTTPRequest) (message.AgentMessage, error) { + return request.Decode(req) +} + +type carInbound struct { + codec transport.InboundAcceptCodec +} + +func (ic *carInbound) Accept(req transport.HTTPRequest) (transport.InboundAcceptCodec, transport.HTTPError) { + // TODO: select a decoder - we only support 1 ATM + contentType := req.Headers().Get("Content-Type") + if contentType != request.ContentType { + headers := http.Header{} + headers.Set("Accept", contentType) + return nil, thttp.NewHTTPError( + "The server cannot process the request because the payload format is not supported. Please check the content-type header and try again with a supported media type.", + http.StatusUnsupportedMediaType, + headers, + ) + } + + // TODO: select an encoder by desired preference (q=) - we only support 1 ATM + accept := req.Headers().Get("Accept") + if accept == "" { + accept = "*/*" + } + if accept != "*/*" && !strings.Contains(accept, contentType) { + headers := http.Header{} + headers.Set("Accept", contentType) + return nil, thttp.NewHTTPError( + "The requested resource cannot be served in the requested content type. Please specify a supported content type using the Accept header.", + http.StatusNotAcceptable, + headers, + ) + } + + return ic.codec, nil +} + +var _ transport.InboundCodec = (*carInbound)(nil) + +func NewCARInboundCodec() transport.InboundCodec { + return &carInbound{codec: &carInboundAcceptCodec{}} +} diff --git a/transport/car/request/request.go b/transport/car/request/request.go index 88f677b..8f37d13 100644 --- a/transport/car/request/request.go +++ b/transport/car/request/request.go @@ -1,13 +1,15 @@ package request import ( + "fmt" "net/http" - "github.com/ipld/go-ipld-prime" - "github.com/web3-storage/go-ucanto/core/car" - "github.com/web3-storage/go-ucanto/core/message" - "github.com/web3-storage/go-ucanto/transport" - uhttp "github.com/web3-storage/go-ucanto/transport/http" + "github.com/storacha-network/go-ucanto/core/car" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/message" + "github.com/storacha-network/go-ucanto/transport" + uhttp "github.com/storacha-network/go-ucanto/transport/http" ) const ContentType = car.ContentType @@ -20,3 +22,15 @@ func Encode(message message.AgentMessage) (transport.HTTPRequest, error) { reader := car.Encode([]ipld.Link{message.Root().Link()}, message.Blocks()) return uhttp.NewHTTPRequest(reader, headers), nil } + +func Decode(req transport.HTTPRequest) (message.AgentMessage, error) { + roots, blocks, err := car.Decode(req.Body()) + if err != nil { + return nil, fmt.Errorf("decoding CAR: %s", err) + } + bstore, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(blocks)) + if err != nil { + return nil, fmt.Errorf("creating blockstore: %s", err) + } + return message.NewMessage(roots, bstore) +} diff --git a/transport/car/response/response.go b/transport/car/response/response.go index 05b4049..2b97be7 100644 --- a/transport/car/response/response.go +++ b/transport/car/response/response.go @@ -2,19 +2,29 @@ package response import ( "fmt" + "net/http" - "github.com/web3-storage/go-ucanto/core/car" - "github.com/web3-storage/go-ucanto/core/dag/blockstore" - "github.com/web3-storage/go-ucanto/core/message" - "github.com/web3-storage/go-ucanto/transport" + "github.com/storacha-network/go-ucanto/core/car" + "github.com/storacha-network/go-ucanto/core/dag/blockstore" + "github.com/storacha-network/go-ucanto/core/ipld" + "github.com/storacha-network/go-ucanto/core/message" + "github.com/storacha-network/go-ucanto/transport" + uhttp "github.com/storacha-network/go-ucanto/transport/http" ) const ContentType = car.ContentType +func Encode(msg message.AgentMessage) (transport.HTTPResponse, error) { + headers := http.Header{} + headers.Add("Content-Type", car.ContentType) + reader := car.Encode([]ipld.Link{msg.Root().Link()}, msg.Blocks()) + return uhttp.NewHTTPResponse(http.StatusOK, reader, headers), nil +} + func Decode(response transport.HTTPResponse) (message.AgentMessage, error) { roots, blocks, err := car.Decode(response.Body()) if err != nil { - return nil, fmt.Errorf("decoding response: %s", err) + return nil, fmt.Errorf("decoding CAR: %s", err) } bstore, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(blocks)) if err != nil { diff --git a/transport/codec.go b/transport/codec.go index 2274d0e..224a8d1 100644 --- a/transport/codec.go +++ b/transport/codec.go @@ -1,13 +1,21 @@ package transport import ( - "github.com/web3-storage/go-ucanto/core/message" + "github.com/storacha-network/go-ucanto/core/message" ) type RequestEncoder interface { Encode(message message.AgentMessage) (HTTPRequest, error) } +type RequestDecoder interface { + Decode(request HTTPRequest) (message.AgentMessage, error) +} + +type ResponseEncoder interface { + Encode(message message.AgentMessage) (HTTPResponse, error) +} + type ResponseDecoder interface { Decode(response HTTPResponse) (message.AgentMessage, error) } @@ -16,3 +24,16 @@ type OutboundCodec interface { RequestEncoder ResponseDecoder } + +type InboundAcceptCodec interface { + // Decoder will be used by a server to decode HTTP Request into an invocation + // `Batch` that will be executed using a `service`. + Decoder() RequestDecoder + // Encoder will be used to encode batch of invocation results into an HTTP + // response that will be sent back to the client that initiated the request. + Encoder() ResponseEncoder +} + +type InboundCodec interface { + Accept(request HTTPRequest) (InboundAcceptCodec, HTTPError) +} diff --git a/transport/http.go b/transport/http.go index 6b4e1bc..7addfcf 100644 --- a/transport/http.go +++ b/transport/http.go @@ -3,6 +3,8 @@ package transport import ( "io" "net/http" + + "github.com/storacha-network/go-ucanto/core/result" ) type HTTPRequest interface { @@ -11,5 +13,13 @@ type HTTPRequest interface { } type HTTPResponse interface { - HTTPRequest + Status() int + Headers() http.Header + Body() io.Reader +} + +type HTTPError interface { + result.Failure + Status() int + Headers() http.Header } diff --git a/transport/http/channel.go b/transport/http/channel.go index 6a0d073..0f2145d 100644 --- a/transport/http/channel.go +++ b/transport/http/channel.go @@ -5,7 +5,7 @@ import ( "net/http" "net/url" - "github.com/web3-storage/go-ucanto/transport" + "github.com/storacha-network/go-ucanto/transport" ) type channel struct { @@ -24,8 +24,11 @@ func (c *channel) Request(req transport.HTTPRequest) (transport.HTTPResponse, er if err != nil { return nil, fmt.Errorf("doing HTTP request: %s", err) } + if res.StatusCode != http.StatusOK { + return nil, NewHTTPError(fmt.Sprintf("HTTP Request failed. %s %s → %d", hr.Method, c.url.String(), res.StatusCode), res.StatusCode, res.Header) + } - return NewHTTPResponse(res.Body, res.Header), nil + return NewHTTPResponse(res.StatusCode, res.Body, res.Header), nil } func NewHTTPChannel(url *url.URL) transport.Channel { diff --git a/transport/http/error.go b/transport/http/error.go new file mode 100644 index 0000000..e9ec765 --- /dev/null +++ b/transport/http/error.go @@ -0,0 +1,33 @@ +package http + +import ( + nethttp "net/http" + + "github.com/storacha-network/go-ucanto/transport" +) + +type httpError struct { + message string + status int + headers nethttp.Header +} + +func (err *httpError) Error() string { + return err.message +} + +func (err *httpError) Name() string { + return "HTTPError" +} + +func (err *httpError) Status() int { + return err.status +} + +func (err *httpError) Headers() nethttp.Header { + return err.headers +} + +func NewHTTPError(message string, status int, headers nethttp.Header) transport.HTTPError { + return &httpError{message, status, headers} +} diff --git a/transport/http/response.go b/transport/http/response.go index 76bee63..8935776 100644 --- a/transport/http/response.go +++ b/transport/http/response.go @@ -4,14 +4,32 @@ import ( "io" "net/http" - "github.com/web3-storage/go-ucanto/transport" + "github.com/storacha-network/go-ucanto/transport" ) -type response struct { +type request struct { hdrs http.Header body io.Reader } +func (req *request) Headers() http.Header { + return req.hdrs +} + +func (req *request) Body() io.Reader { + return req.body +} + +type response struct { + status int + hdrs http.Header + body io.Reader +} + +func (res *response) Status() int { + return res.status +} + func (res *response) Headers() http.Header { return res.hdrs } @@ -20,10 +38,10 @@ func (res *response) Body() io.Reader { return res.body } -func NewHTTPResponse(body io.Reader, headers http.Header) transport.HTTPResponse { - return &response{headers, body} +func NewHTTPResponse(status int, body io.Reader, headers http.Header) transport.HTTPResponse { + return &response{status, headers, body} } func NewHTTPRequest(body io.Reader, headers http.Header) transport.HTTPRequest { - return &response{headers, body} + return &request{headers, body} } diff --git a/ucan/crypto/signer.go b/ucan/crypto/signer.go index d1710aa..77c5cf2 100644 --- a/ucan/crypto/signer.go +++ b/ucan/crypto/signer.go @@ -1,7 +1,7 @@ package crypto import ( - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" ) // Signer is an entity that can sign a payload. diff --git a/ucan/datamodel/payload/payload.go b/ucan/datamodel/payload/payload.go index 301fdf8..2cde3ce 100644 --- a/ucan/datamodel/payload/payload.go +++ b/ucan/datamodel/payload/payload.go @@ -7,7 +7,7 @@ import ( "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/schema" - udm "github.com/web3-storage/go-ucanto/ucan/datamodel/ucan" + udm "github.com/storacha-network/go-ucanto/ucan/datamodel/ucan" ) //go:embed payload.ipldsch diff --git a/ucan/formatter/formatter.go b/ucan/formatter/formatter.go index 5482877..fc5a499 100644 --- a/ucan/formatter/formatter.go +++ b/ucan/formatter/formatter.go @@ -6,9 +6,9 @@ import ( "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/codec/dagjson" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" - hdm "github.com/web3-storage/go-ucanto/ucan/datamodel/header" - pdm "github.com/web3-storage/go-ucanto/ucan/datamodel/payload" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" + hdm "github.com/storacha-network/go-ucanto/ucan/datamodel/header" + pdm "github.com/storacha-network/go-ucanto/ucan/datamodel/payload" ) func FormatSignPayload(header *hdm.HeaderModel, payload *pdm.PayloadModel) (string, error) { diff --git a/ucan/lib.go b/ucan/lib.go index 72509b2..6d8bacd 100644 --- a/ucan/lib.go +++ b/ucan/lib.go @@ -5,10 +5,10 @@ import ( "time" "github.com/ipld/go-ipld-prime/datamodel" - hdm "github.com/web3-storage/go-ucanto/ucan/datamodel/header" - pdm "github.com/web3-storage/go-ucanto/ucan/datamodel/payload" - udm "github.com/web3-storage/go-ucanto/ucan/datamodel/ucan" - "github.com/web3-storage/go-ucanto/ucan/formatter" + hdm "github.com/storacha-network/go-ucanto/ucan/datamodel/header" + pdm "github.com/storacha-network/go-ucanto/ucan/datamodel/payload" + udm "github.com/storacha-network/go-ucanto/ucan/datamodel/ucan" + "github.com/storacha-network/go-ucanto/ucan/formatter" ) const version = "0.9.1" diff --git a/ucan/ucan.go b/ucan/ucan.go index 334eadc..5ef1de4 100644 --- a/ucan/ucan.go +++ b/ucan/ucan.go @@ -2,9 +2,9 @@ package ucan import ( "github.com/ipld/go-ipld-prime" - "github.com/web3-storage/go-ucanto/did" - "github.com/web3-storage/go-ucanto/ucan/crypto" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/ucan/crypto" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" ) // Resorce is a string that represents resource a UCAN holder can act upon. diff --git a/ucan/view.go b/ucan/view.go index 1e51309..e8868fd 100644 --- a/ucan/view.go +++ b/ucan/view.go @@ -3,9 +3,9 @@ package ucan import ( "fmt" - "github.com/web3-storage/go-ucanto/did" - "github.com/web3-storage/go-ucanto/ucan/crypto/signature" - udm "github.com/web3-storage/go-ucanto/ucan/datamodel/ucan" + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/ucan/crypto/signature" + udm "github.com/storacha-network/go-ucanto/ucan/datamodel/ucan" ) // UCANView represents a decoded "view" of a UCAN that can be used in your diff --git a/validator/authorization.go b/validator/authorization.go new file mode 100644 index 0000000..e5c5e05 --- /dev/null +++ b/validator/authorization.go @@ -0,0 +1,3 @@ +package validator + +type Authorization interface{} diff --git a/validator/lib.go b/validator/lib.go new file mode 100644 index 0000000..19ce12e --- /dev/null +++ b/validator/lib.go @@ -0,0 +1,10 @@ +package validator + +import ( + "github.com/storacha-network/go-ucanto/did" + "github.com/storacha-network/go-ucanto/ucan" +) + +func IsSelfIssued[Caveats any](capability ucan.Capability[Caveats], issuer did.DID) bool { + return capability.With() == issuer.DID().String() +} diff --git a/validator/lib_test.go b/validator/lib_test.go new file mode 100644 index 0000000..c64b66e --- /dev/null +++ b/validator/lib_test.go @@ -0,0 +1,31 @@ +package validator + +import ( + "testing" + + "github.com/storacha-network/go-ucanto/principal/ed25519/signer" + "github.com/storacha-network/go-ucanto/ucan" +) + +func TestIsSelfIssued(t *testing.T) { + alice, err := signer.Generate() + if err != nil { + t.Fatalf("generating key: %v", err) + } + bob, err := signer.Generate() + if err != nil { + t.Fatalf("generating key: %v", err) + } + + cap := ucan.NewCapability("upload/add", alice.DID().String(), struct{}{}) + + canIssue := IsSelfIssued(cap, alice.DID()) + if canIssue == false { + t.Fatal("capability self issued by alice") + } + + canIssue = IsSelfIssued(cap, bob.DID()) + if canIssue == true { + t.Fatal("capability not self issued by bob") + } +}